From 5290dd80cadf86ae2c668352dd06eb4e6c457114 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:48:12 -0400 Subject: [PATCH] Add spelling practice activity log for parents Shows per-student, per-day practice history in the Spelling panel: date, time range, word list(s), and first-try correct rate. Clicking a row opens a detail modal with every word attempted, what the student typed, and whether it was a first try or retype. Co-Authored-By: Claude Sonnet 4.6 --- public/admin.html | 19 ++++++++++++ public/admin.js | 78 +++++++++++++++++++++++++++++++++++++++++++++++ server.js | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/public/admin.html b/public/admin.html index 5e1944a..b2421b7 100644 --- a/public/admin.html +++ b/public/admin.html @@ -406,6 +406,25 @@

Spelling

+ +

Practice Activity

+
+ +
+
+ + +

This week's list per kid

diff --git a/public/admin.js b/public/admin.js index dddf91a..337ca5d 100644 --- a/public/admin.js +++ b/public/admin.js @@ -2088,8 +2088,86 @@ async function loadSpelling() { cachedStudents = overview.students; renderAssignRows(overview.students); renderLists(lists); + loadPracticeActivity(); } +async function loadPracticeActivity() { + const since = $('#practice-since').value; + const url = since ? `/api/spelling-activity?since=${since}` : '/api/spelling-activity'; + const container = $('#practice-activity-table'); + try { + const { students } = await api(url); + if (!students.length) { + container.innerHTML = `

No practice recorded yet.

`; + return; + } + container.innerHTML = students.map((s) => { + const rows = s.days.map((d) => { + const pct = d.attempted > 0 ? Math.round((d.correct_count / d.attempted) * 100) : null; + const startTime = new Date(d.first_at + 'Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const endTime = new Date(d.last_at + 'Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const dateLabel = new Date(d.day + 'T00:00:00').toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); + const scoreHtml = pct !== null + ? `${d.correct_count}/${d.attempted} (${pct}%)` + : '—'; + return ` + ${dateLabel} + ${startTime}–${endTime} + ${esc(d.lists.join(', '))} + ${scoreHtml} + `; + }).join(''); + return `

${esc(s.emoji)} ${esc(s.name)}

+ ${rows}
DateTimeListsFirst-try score
`; + }).join(''); + container.querySelectorAll('[data-student][data-date]').forEach((row) => + row.addEventListener('click', () => showPracticeDetail(row.dataset.student, row.dataset.date)) + ); + } catch (e) { + container.innerHTML = `

Could not load practice activity.

`; + } +} + +async function showPracticeDetail(studentId, date) { + const dateLabel = new Date(date + 'T00:00:00').toLocaleDateString([], { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + $('#practice-modal-title').textContent = `Practice on ${dateLabel}`; + $('#practice-modal-body').innerHTML = `

Loading…

`; + $('#practice-modal').hidden = false; + try { + const { words } = await api(`/api/spelling-activity/${studentId}/${date}`); + if (!words.length) { + $('#practice-modal-body').innerHTML = `

No attempts recorded.

`; + return; + } + const byList = {}; + for (const w of words) { + if (!byList[w.list_name]) byList[w.list_name] = []; + byList[w.list_name].push(w); + } + $('#practice-modal-body').innerHTML = Object.entries(byList).map(([list, ws]) => { + const rows = ws.map((w) => { + const time = new Date(w.at + 'Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const modeIcon = w.mode === 'practice' ? '✏️' : '🔁'; + const correct = Number(w.correct); + return ` + ${time} + ${modeIcon} + ${esc(w.word)} + ${esc(w.typed)} + ${correct ? '✓' : '✗'} + `; + }).join(''); + return `

${esc(list)}

+ ${rows}
TimeWordTyped
`; + }).join(''); + } catch { + $('#practice-modal-body').innerHTML = `

Could not load detail.

`; + } +} + +$('#practice-modal-close').addEventListener('click', () => { $('#practice-modal').hidden = true; }); +$('#practice-since').addEventListener('change', loadPracticeActivity); + function renderAssignRows(students) { const options = (sel) => `` + diff --git a/server.js b/server.js index c602c4d..22ab625 100644 --- a/server.js +++ b/server.js @@ -544,6 +544,59 @@ app.get('/api/test-report/:testId', (req, res) => { res.json(test); }); +// ---------- spelling practice activity (parent view) ---------- + +app.get('/api/spelling-activity', requirePin, (req, res) => { + const sinceArg = req.query.since ? Math.max(1, Math.min(365, Number(req.query.since))) : null; + const sinceDate = sinceArg ? new Date(Date.now() - sinceArg * 86400000).toISOString().slice(0, 10) : null; + + const students = db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all(); + const dayWithSince = db.prepare(` + SELECT date(a.at) AS day, MIN(a.at) AS first_at, MAX(a.at) AS last_at, + COUNT(CASE WHEN a.mode = 'practice' THEN 1 END) AS attempted, + SUM(CASE WHEN a.mode = 'practice' AND a.correct = 1 THEN 1 ELSE 0 END) AS correct_count, + GROUP_CONCAT(DISTINCT l.name) AS lists + FROM attempts a + JOIN words w ON w.id = a.word_id + JOIN lists l ON l.id = w.list_id + WHERE a.student_id = ? AND date(a.at) >= ? + GROUP BY date(a.at) + ORDER BY day DESC + `); + const dayAll = db.prepare(` + SELECT date(a.at) AS day, MIN(a.at) AS first_at, MAX(a.at) AS last_at, + COUNT(CASE WHEN a.mode = 'practice' THEN 1 END) AS attempted, + SUM(CASE WHEN a.mode = 'practice' AND a.correct = 1 THEN 1 ELSE 0 END) AS correct_count, + GROUP_CONCAT(DISTINCT l.name) AS lists + FROM attempts a + JOIN words w ON w.id = a.word_id + JOIN lists l ON l.id = w.list_id + WHERE a.student_id = ? + GROUP BY date(a.at) + ORDER BY day DESC + `); + + const result = students.map((s) => { + const rows = sinceDate ? dayWithSince.all(s.id, sinceDate) : dayAll.all(s.id); + return { ...s, days: rows.map((d) => ({ ...d, lists: d.lists ? d.lists.split(',') : [] })) }; + }).filter((s) => s.days.length > 0); + + res.json({ students: result }); +}); + +app.get('/api/spelling-activity/:studentId/:date', requirePin, (req, res) => { + if (!isDateStr(req.params.date)) return res.status(400).json({ error: 'Invalid date' }); + const words = db.prepare(` + SELECT w.word, a.typed, a.correct, a.mode, a.at, l.name AS list_name + FROM attempts a + JOIN words w ON w.id = a.word_id + JOIN lists l ON l.id = w.list_id + WHERE a.student_id = ? AND date(a.at) = ? + ORDER BY a.at + `).all(req.params.studentId, req.params.date); + res.json({ words }); +}); + // ---------- word lists (read is public, kids never write) ---------- app.get('/api/lists', (req, res) => {