From b048ebbd85663414f80e2d471cf6be959b9b602c Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:26:47 -0400 Subject: [PATCH] Add spelling test polish: date filter, CSV export, student history, planner badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gradebook: period dropdown (all time / 30 / 90 days) and CSV export for Spelling Test History section - Student portal spelling tab: shows last 10 tests as a Recent Tests list - Planner: completed standalone spelling tests appear as read-only ⭐ badges on the day they were taken (week and month views) - Removed dead s.tests query from /api/overview (now served by dedicated endpoints) - New endpoints: GET /api/spelling-tests/csv, GET /api/students/:id/tests, GET /api/admin/students/:id/tests-range Co-Authored-By: Claude Sonnet 4.6 --- public/admin.html | 8 +++++++ public/admin.js | 32 +++++++++++++++++++------ public/app.js | 22 +++++++++++++++--- public/index.html | 1 + public/styles.css | 8 +++++++ server.js | 59 +++++++++++++++++++++++++++++++++++++++-------- 6 files changed, 110 insertions(+), 20 deletions(-) diff --git a/public/admin.html b/public/admin.html index 6f7c421..e3fe368 100644 --- a/public/admin.html +++ b/public/admin.html @@ -284,6 +284,14 @@

Spelling Test History

+
+ + +
diff --git a/public/styles.css b/public/styles.css index b580172..c716679 100644 --- a/public/styles.css +++ b/public/styles.css @@ -487,6 +487,7 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; } .planner-task[draggable] { cursor: grab; } .planner-task[draggable]:active { cursor: grabbing; } .planner-task.dragging { opacity: 0.35; } +.planner-test-badge { background: color-mix(in srgb, var(--accent) 18%, var(--card)); border-left: 3px solid var(--accent); font-size: 0.78rem; cursor: default; pointer-events: none; } .planner-tasks.drop-target { background: color-mix(in srgb, var(--accent) 12%, transparent); border-radius: 8px; @@ -724,3 +725,10 @@ body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; } .theme-swatch.theme-purple { background: #7c5cbf; } .theme-swatch.theme-orange { background: #e8802a; } .theme-swatch.theme-pink { background: #d45d8a; } + +/* ---------- student portal: test history ---------- */ +.thr-heading { margin: 1.5rem 0 0.5rem; font-size: 1rem; } +.test-history-row { display: flex; gap: 0.75rem; align-items: baseline; padding: 0.35rem 0; border-bottom: 1px solid var(--muted); font-size: 0.9rem; flex-wrap: wrap; } +.thr-date { color: var(--muted); min-width: 6rem; flex-shrink: 0; } +.thr-list { flex: 1; min-width: 8rem; } +.thr-score { margin-left: auto; font-weight: 600; white-space: nowrap; } diff --git a/server.js b/server.js index 24e51ec..0323e09 100644 --- a/server.js +++ b/server.js @@ -445,11 +445,6 @@ app.get('/api/overview', requirePin, (req, res) => { WHERE w.list_id = ? `).get(s.id, s.assignment.id); } - s.tests = db.prepare(` - SELECT t.id, t.score, t.total, t.at, l.name AS list - FROM tests t JOIN lists l ON l.id = t.list_id - WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20 - `).all(s.id); } res.json({ students }); }); @@ -970,17 +965,61 @@ app.get('/api/gradebook/:courseId/csv', requirePin, (req, res) => { // All standalone spelling test results grouped by student app.get('/api/spelling-tests', requirePin, (req, res) => { + const days = parseInt(req.query.since) || null; + const cutoff = days ? new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) : null; const students = db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all(); for (const s of students) { - s.tests = db.prepare(` - SELECT t.id, t.score, t.total, t.at, l.name AS list - FROM tests t JOIN lists l ON l.id = t.list_id - WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20 - `).all(s.id); + s.tests = cutoff + ? db.prepare(`SELECT t.id, t.score, t.total, t.at, l.name AS list FROM tests t JOIN lists l ON l.id = t.list_id WHERE t.student_id = ? AND date(t.at) >= ? ORDER BY t.at DESC LIMIT 20`).all(s.id, cutoff) + : db.prepare(`SELECT t.id, t.score, t.total, t.at, l.name AS list FROM tests t JOIN lists l ON l.id = t.list_id WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20`).all(s.id); } res.json({ students }); }); +app.get('/api/spelling-tests/csv', requirePin, (req, res) => { + const days = parseInt(req.query.since) || null; + const cutoff = days ? new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) : null; + const rows = db.prepare(` + SELECT s.emoji, s.name AS student, l.name AS list, t.score, t.total, t.at + FROM tests t JOIN students s ON s.id = t.student_id JOIN lists l ON l.id = t.list_id + ${cutoff ? 'WHERE date(t.at) >= ?' : ''} ORDER BY t.at DESC + `).all(...(cutoff ? [cutoff] : [])); + const q = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const header = 'Date,Student,List,Score,Total,%'; + const data = rows.map((r) => [ + q(new Date(r.at + 'Z').toLocaleDateString()), + q(`${r.emoji} ${r.student}`), + q(r.list), r.score, r.total, + Math.round((r.score / r.total) * 100) + '%', + ].join(',')).join('\n'); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename="spelling-test-history.csv"'); + res.send(header + '\n' + data); +}); + +// Student-facing: recent tests for the spelling tab (no PIN — student portal) +app.get('/api/students/:id/tests', (req, res) => { + const tests = db.prepare(` + SELECT t.id, t.score, t.total, t.at, l.name AS list + FROM tests t JOIN lists l ON l.id = t.list_id + WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 10 + `).all(req.params.id); + res.json({ tests }); +}); + +// Admin: tests within a date range for a single student (used by planner) +app.get('/api/admin/students/:id/tests-range', requirePin, (req, res) => { + const { from, to } = req.query; + if (!from || !to) return res.status(400).json({ error: 'from and to required' }); + const tests = db.prepare(` + SELECT t.id, t.score, t.total, t.at, date(t.at) AS date, l.name AS list + FROM tests t JOIN lists l ON l.id = t.list_id + WHERE t.student_id = ? AND date(t.at) BETWEEN ? AND ? + ORDER BY t.at + `).all(req.params.id, from, to); + res.json({ tests }); +}); + // 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);