From a7de24b1da6fb12912bf2c79798deba89bb5dec4 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:33:42 -0400 Subject: [PATCH] Add evidence submission, 3-state offline tasks, parent comments, and retake policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Evidence on assignments: photo upload + notes + student self-note (needs help / not sure) - Evidence required/optional/none flag per item; grading queue shows evidence with lightbox thumbnail - Parent comment field when grading; shown to student on graded items - Offline tasks: 3-state status (not started β†’ in progress β†’ done) with inline evidence form - Retake policy per item: latest / highest / average β€” gradebook computes effective score accordingly - Grading queue expanded to include lessons and practices (not just assignments) when evidence present Co-Authored-By: Claude Sonnet 4.6 --- db.js | 9 +++ public/admin.html | 24 ++++++++ public/admin.js | 75 +++++++++++++++++++----- public/app.js | 145 ++++++++++++++++++++++++++++++++++++++++++---- public/index.html | 21 +++++++ public/styles.css | 28 +++++++++ server.js | 107 ++++++++++++++++++++++++---------- 7 files changed, 351 insertions(+), 58 deletions(-) diff --git a/db.js b/db.js index 770bf6f..5e6a3fd 100644 --- a/db.js +++ b/db.js @@ -214,6 +214,15 @@ function tryAlter(sql) { 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`); +tryAlter(`ALTER TABLE items ADD COLUMN evidence_mode TEXT NOT NULL DEFAULT 'none'`); +tryAlter(`ALTER TABLE items ADD COLUMN retake_policy TEXT NOT NULL DEFAULT 'latest'`); +tryAlter(`ALTER TABLE submissions ADD COLUMN evidence_notes TEXT`); +tryAlter(`ALTER TABLE submissions ADD COLUMN evidence_photo TEXT`); +tryAlter(`ALTER TABLE submissions ADD COLUMN student_note TEXT`); +tryAlter(`ALTER TABLE submissions ADD COLUMN parent_comment TEXT`); +tryAlter(`ALTER TABLE schedule ADD COLUMN status TEXT NOT NULL DEFAULT 'not_started'`); +tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_notes TEXT`); +tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_photo TEXT`); export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex'); diff --git a/public/admin.html b/public/admin.html index e94b455..a8ed4f0 100644 --- a/public/admin.html +++ b/public/admin.html @@ -120,6 +120,22 @@ +
+ + +
+ + + + + diff --git a/public/styles.css b/public/styles.css index e98716c..a8d5e9f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -416,3 +416,31 @@ td.overdue, .overdue { background: var(--bad-bg) !important; } } .modal-box h3 { margin-bottom: 1rem; } .modal-box .secondary { margin-top: 1rem; } + +/* ---------- evidence forms ---------- */ +.evidence-form { margin: 0.75rem 0; border: 1.5px dashed var(--accent); border-radius: 10px; padding: 0.75rem; } +.evidence-form summary { cursor: pointer; color: var(--accent); font-weight: 600; } +.evidence-inner { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem; } +.evidence-inner textarea { width: 100%; border: 1.5px solid #d8d2c0; border-radius: 8px; padding: 0.5rem; font-family: inherit; font-size: 1rem; resize: vertical; } +.evidence-photo-row { display: flex; align-items: center; gap: 0.5rem; } +.evidence-note-label select { width: 100%; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; } +.parent-comment { background: var(--good-bg); border-radius: 10px; padding: 0.75rem 1rem; margin: 0.75rem 0; color: var(--good); } +.status-banner.bad { background: var(--bad-bg); color: var(--bad); border-radius: 10px; padding: 0.75rem 1rem; margin: 0.75rem 0; } + +/* ---------- offline 3-state tasks ---------- */ +.today-row-offline { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 0.5rem; text-align: left; } +.offline-main { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.offline-main .row-title { flex: 1; font-weight: 600; } +.offline-controls { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 0.5rem; } +.offline-status-btn { padding: 0.3rem 0.75rem; border: 1.5px solid var(--muted); border-radius: 20px; background: transparent; font-size: 0.85rem; cursor: pointer; } +.offline-status-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; } +.offline-evidence-form { border: none; padding: 0; } +.offline-evidence-form summary { font-size: 0.85rem; color: var(--muted); cursor: pointer; } + +/* ---------- grading queue cards ---------- */ +.grading-card { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; } +.grading-header { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.6rem; } +.grading-header .grow { flex: 1; font-weight: 600; } +.grading-footer { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.6rem; } +.comment-input { flex: 1; min-width: 140px; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; } +.badge-tag { background: var(--gold); color: #333; border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.8rem; font-weight: 600; } diff --git a/server.js b/server.js index e29ccb1..964e5b4 100644 --- a/server.js +++ b/server.js @@ -462,7 +462,7 @@ app.get('/api/admin/courses/:id', requirePin, (req, res) => { // 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, due_date, allow_retakes, prereq_item_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, evidence_mode, retake_policy 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(` @@ -543,14 +543,15 @@ 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, dueDate, allowRetakes, prereqItemId } = req.body; + const { unitId, type, title, body, points, refId, dueDate, allowRetakes, prereqItemId, evidenceMode, retakePolicy } = 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, due_date, allow_retakes, prereq_item_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO items (unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, prereq_item_id, evidence_mode, retake_policy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(unitId, type, String(title).trim(), String(body || ''), Number(points) || 0, (type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null, - sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null).lastInsertRowid; + sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null, + evidenceMode || 'none', retakePolicy || 'latest').lastInsertRowid; if (type === 'quiz') { saveQuizQuestions(id, req.body.questions); const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0); @@ -565,14 +566,15 @@ 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, dueDate, allowRetakes, prereqItemId } = req.body; + const { type, title, body, points, refId, dueDate, allowRetakes, prereqItemId, evidenceMode, retakePolicy } = 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 = ?, due_date = ?, allow_retakes = ?, prereq_item_id = ? WHERE id = ?`) + db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ?, due_date = ?, allow_retakes = ?, prereq_item_id = ?, evidence_mode = ?, retake_policy = ? WHERE id = ?`) .run(type, String(title).trim(), String(body || ''), finalPoints, (type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null, - dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null, req.params.id); + dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null, + evidenceMode || 'none', retakePolicy || 'latest', req.params.id); if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions); res.json({ ok: true }); }); @@ -640,7 +642,7 @@ 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, i.due_date, i.allow_retakes, i.prereq_item_id, + SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, i.due_date, i.allow_retakes, i.prereq_item_id, i.evidence_mode, i.retake_policy, 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 = ? @@ -655,7 +657,7 @@ app.get('/api/items/:id', (req, res) => { } item.submission = db.prepare(` - SELECT status, score, points_possible, answers, completed_at, graded_at + SELECT status, score, points_possible, answers, completed_at, graded_at, parent_comment, student_note FROM submissions WHERE student_id = ? AND item_id = ? `).get(studentId, req.params.id) || null; @@ -681,25 +683,31 @@ 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 unless allow_retakes is set. app.post('/api/items/:id/complete', (req, res) => { - const { studentId, date } = req.body; + const { studentId, date, evidenceNotes, evidencePhoto, studentNote } = req.body; 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' }); if (item.allow_retakes) { db.prepare(` - INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at) - VALUES (?, ?, 'done', ?, datetime('now')) + INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at, evidence_notes, evidence_photo, student_note) + 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); + completed_at = datetime('now'), score = NULL, graded_at = NULL, + evidence_notes = excluded.evidence_notes, evidence_photo = excluded.evidence_photo, + student_note = excluded.student_note + `).run(studentId, req.params.id, item.points || null, evidenceNotes || null, evidencePhoto || null, studentNote || 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') + INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at, evidence_notes, evidence_photo, student_note) + VALUES (?, ?, 'done', ?, datetime('now'), ?, ?, ?) + ON CONFLICT (student_id, item_id) DO UPDATE SET + completed_at = datetime('now'), + evidence_notes = COALESCE(excluded.evidence_notes, evidence_notes), + evidence_photo = COALESCE(excluded.evidence_photo, evidence_photo), + student_note = COALESCE(excluded.student_note, student_note) WHERE submissions.status != 'graded' - `).run(studentId, req.params.id, item.points || null); + `).run(studentId, req.params.id, item.points || null, evidenceNotes || null, evidencePhoto || null, studentNote || null); } markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today()); @@ -742,15 +750,16 @@ app.post('/api/items/:id/quiz-submit', (req, res) => { app.get('/api/grading-queue', requirePin, (req, res) => { res.json(db.prepare(` SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible, + sub.evidence_notes, sub.evidence_photo, sub.student_note, s.id AS studentId, s.name AS studentName, s.emoji, - i.id AS itemId, i.title AS itemTitle, + i.id AS itemId, i.title AS itemTitle, i.type AS itemType, c.name AS courseName, u.name AS unitName FROM submissions sub JOIN students s ON s.id = sub.student_id JOIN items i ON i.id = sub.item_id JOIN units u ON u.id = i.unit_id JOIN courses c ON c.id = u.course_id - WHERE sub.status = 'done' AND i.type = 'assignment' + WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards') ORDER BY sub.completed_at `).all()); }); @@ -758,8 +767,12 @@ app.get('/api/grading-queue', requirePin, (req, res) => { app.put('/api/submissions/:id/grade', requirePin, (req, res) => { const score = Number(req.body.score); if (Number.isNaN(score)) return res.status(400).json({ error: 'Score must be a number' }); - db.prepare(`UPDATE submissions SET score = ?, status = 'graded', graded_at = datetime('now') WHERE id = ?`) - .run(score, req.params.id); + const comment = req.body.parentComment !== undefined ? String(req.body.parentComment).trim() || null : undefined; + db.prepare(` + UPDATE submissions SET score = ?, status = 'graded', graded_at = datetime('now') + ${comment !== undefined ? ', parent_comment = ?' : ''} + WHERE id = ? + `).run(...(comment !== undefined ? [score, comment, req.params.id] : [score, req.params.id])); res.json({ ok: true }); }); @@ -770,7 +783,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, i.due_date + SELECT i.id, i.title, i.type, i.points, i.due_date, i.retake_policy 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 @@ -781,7 +794,9 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => { 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 scoreStmt = db.prepare(`SELECT score, points_possible, status, parent_comment FROM submissions WHERE student_id = ? AND item_id = ?`); + const histBest = db.prepare(`SELECT MAX(score * 1.0 / points_possible) AS ratio, MAX(score) AS score, points_possible FROM submission_history WHERE student_id = ? AND item_id = ? AND points_possible > 0`); + const histAvg = db.prepare(`SELECT AVG(score) AS score, points_possible, COUNT(*) AS cnt FROM submission_history WHERE student_id = ? AND item_id = ? AND points_possible > 0`); const now = today(); for (const s of students) { s.scores = {}; @@ -789,9 +804,17 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => { for (const item of gradableItems) { const row = scoreStmt.get(s.id, item.id); const overdue = !!(item.due_date && item.due_date < now && (!row || row.status !== 'graded')); - s.scores[item.id] = row ? { ...row, overdue } : (overdue ? { overdue: true } : null); + let effectiveScore = row?.score; + if (row?.status === 'graded' && item.retake_policy === 'highest') { + const best = histBest.get(s.id, item.id); + if (best?.score !== null) effectiveScore = best.score; + } else if (row?.status === 'graded' && item.retake_policy === 'average') { + const avg = histAvg.get(s.id, item.id); + if (avg?.cnt > 0) effectiveScore = Math.round(avg.score * 10) / 10; + } + s.scores[item.id] = row ? { ...row, score: effectiveScore, overdue } : (overdue ? { overdue: true } : null); if (row && row.status === 'graded' && row.points_possible) { - earned += row.score; + earned += effectiveScore ?? 0; possible += row.points_possible; } } @@ -839,6 +862,20 @@ app.get('/api/gradebook/:courseId/csv', requirePin, (req, res) => { res.send([header, ...rows].map((r) => r.join(',')).join('\n')); }); +// Full evidence for a submission (photo can be large, not included in queue list) +app.get('/api/grading-queue/:id/evidence', requirePin, (req, res) => { + const row = db.prepare(`SELECT evidence_notes, evidence_photo FROM submissions WHERE id = ?`).get(req.params.id); + if (!row) return res.status(404).json({ error: 'Not found' }); + res.json(row); +}); + +// Evidence detail for a schedule item (offline task) +app.get('/api/schedule/:id/evidence', requirePin, (req, res) => { + const row = db.prepare(`SELECT evidence_notes, evidence_photo FROM schedule WHERE id = ?`).get(req.params.id); + if (!row) return res.status(404).json({ error: 'No such task' }); + res.json(row); +}); + // Score history for a student on one item app.get('/api/items/:id/history', (req, res) => { const { studentId } = req.query; @@ -898,7 +935,8 @@ app.get('/api/schedule/:studentId', (req, res) => { const date = isDateStr(req.query.date) ? req.query.date : null; if (!date) return res.status(400).json({ error: 'date=YYYY-MM-DD required' }); const tasks = db.prepare(` - SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done, + SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done, sc.status AS offlineStatus, + sc.evidence_notes IS NOT NULL OR sc.evidence_photo IS NOT NULL AS hasEvidence, i.id AS itemId, i.type, i.title AS itemTitle, c.name AS courseName, c.color AS courseColor, sub.status AS subStatus, sub.score, sub.points_possible @@ -913,15 +951,20 @@ app.get('/api/schedule/:studentId', (req, res) => { res.json({ tasks }); }); -// Kid toggles a standalone offline task +// Kid updates a standalone offline task: status (not_started/in_progress/done) + optional evidence app.post('/api/schedule/:id/done', (req, res) => { const row = db.prepare(`SELECT item_id FROM schedule WHERE id = ?`).get(req.params.id); if (!row) return res.status(404).json({ error: 'No such task' }); if (row.item_id) return res.status(400).json({ error: 'Course items complete themselves β€” open the item instead' }); - const done = req.body.done ? 1 : 0; + const status = ['not_started', 'in_progress', 'done'].includes(req.body.status) ? req.body.status : (req.body.done ? 'done' : 'not_started'); + const done = status === 'done' ? 1 : 0; db.prepare(` - UPDATE schedule SET done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE id = ? - `).run(done, done, req.params.id); + UPDATE schedule SET + status = ?, done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END, + evidence_notes = COALESCE(?, evidence_notes), + evidence_photo = COALESCE(?, evidence_photo) + WHERE id = ? + `).run(status, done, done, req.body.evidenceNotes || null, req.body.evidencePhoto || null, req.params.id); res.json({ ok: true }); });