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 `| Date | Time | Lists | First-try score |
|---|
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 | Word | Typed |
|---|
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) => {