diff --git a/public/admin.html b/public/admin.html index b2421b7..e6f4373 100644 --- a/public/admin.html +++ b/public/admin.html @@ -407,7 +407,17 @@ Spelling - Practice Activity + Practice Sessions + + Period + Last 30 days + Last 90 days + All time + + + + + Practice Activity (word-by-word) Period Last 30 days diff --git a/public/admin.js b/public/admin.js index 337ca5d..8f6c5bb 100644 --- a/public/admin.js +++ b/public/admin.js @@ -2088,9 +2088,49 @@ async function loadSpelling() { cachedStudents = overview.students; renderAssignRows(overview.students); renderLists(lists); + loadPracticeSessions(); loadPracticeActivity(); } +async function loadPracticeSessions() { + const since = $('#sessions-since').value; + const url = since ? `/api/spelling-sessions?since=${since}` : '/api/spelling-sessions'; + const container = $('#practice-sessions-table'); + try { + const { sessions } = await api(url); + if (!sessions.length) { + container.innerHTML = `No course practice sessions recorded yet. Sessions are tracked when students complete a Spelling Practice item in a course.`; + return; + } + const rows = sessions.map((s) => { + const pct = s.points_possible > 0 ? Math.round((s.score / s.points_possible) * 100) : null; + const scoreHtml = pct !== null + ? `${s.score}/${s.points_possible} (${pct}%)` + : '—'; + const dt = new Date(s.completed_at + 'Z'); + const dateLabel = dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }); + const timeLabel = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return ` + ${esc(s.emoji)} ${esc(s.student_name)} + ${dateLabel} ${timeLabel} + ${esc(s.course_name)} + ${esc(s.item_title)} + ${esc(s.list_name || '—')} + ${scoreHtml} + `; + }).join(''); + container.innerHTML = ` + StudentDateCourseItemListScore + ${rows} + `; + 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 sessions.`; + } +} + async function loadPracticeActivity() { const since = $('#practice-since').value; const url = since ? `/api/spelling-activity?since=${since}` : '/api/spelling-activity'; @@ -2166,6 +2206,7 @@ async function showPracticeDetail(studentId, date) { } $('#practice-modal-close').addEventListener('click', () => { $('#practice-modal').hidden = true; }); +$('#sessions-since').addEventListener('change', loadPracticeSessions); $('#practice-since').addEventListener('change', loadPracticeActivity); function renderAssignRows(students) { diff --git a/public/app.js b/public/app.js index ab8033c..723c54f 100644 --- a/public/app.js +++ b/public/app.js @@ -2164,7 +2164,15 @@ async function nextPracticeWord() { $('#practice-progress').textContent = 'Done!'; $('#practice-card').hidden = true; if (practice.itemId) { - await api(`/api/items/${practice.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); + await api(`/api/items/${practice.itemId}/complete`, { + method: 'POST', + body: { + studentId: currentStudent.id, + date: todayStr(), + sessionAttempted: practice.words.length, + sessionCorrect: practice.firstTryCorrect, + }, + }); } const missedWords = practice.missedWords.slice(); const missedHtml = practice.missed.length diff --git a/server.js b/server.js index 22ab625..b5b72a7 100644 --- a/server.js +++ b/server.js @@ -597,6 +597,30 @@ app.get('/api/spelling-activity/:studentId/:date', requirePin, (req, res) => { res.json({ words }); }); +// Per-session records for spelling_practice course items (one row per completed pass) +app.get('/api/spelling-sessions', 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 base = ` + SELECT sh.id, sh.student_id, sh.item_id, sh.score, sh.points_possible, sh.completed_at, + s.name AS student_name, s.emoji, + i.title AS item_title, + l.name AS list_name, + c.name AS course_name + FROM submission_history sh + JOIN students s ON s.id = sh.student_id + JOIN items i ON i.id = sh.item_id + JOIN units u ON u.id = i.unit_id + JOIN courses c ON c.id = u.course_id + LEFT JOIN lists l ON l.id = i.ref_id + WHERE i.type = 'spelling_practice' + `; + const sessions = sinceDate + ? db.prepare(base + ` AND date(sh.completed_at) >= ? ORDER BY sh.completed_at DESC LIMIT 500`).all(sinceDate) + : db.prepare(base + ` ORDER BY sh.completed_at DESC LIMIT 500`).all(); + res.json({ sessions }); +}); + // ---------- word lists (read is public, kids never write) ---------- app.get('/api/lists', (req, res) => { @@ -1512,8 +1536,8 @@ 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, evidenceNotes, evidencePhoto, studentNote } = req.body; - const item = db.prepare(`SELECT id, points, allow_retakes FROM items WHERE id = ?`).get(req.params.id); + const { studentId, date, evidenceNotes, evidencePhoto, studentNote, sessionAttempted, sessionCorrect } = req.body; + const item = db.prepare(`SELECT id, type, 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) { @@ -1540,6 +1564,13 @@ app.post('/api/items/:id/complete', (req, res) => { } markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today()); + + // Each spelling_practice pass gets its own history entry so the parent sees individual attempts + if (item.type === 'spelling_practice' && sessionAttempted != null) { + db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible) VALUES (?, ?, ?, ?)`) + .run(studentId, req.params.id, sessionCorrect != null ? sessionCorrect : 0, sessionAttempted); + } + res.json({ ok: true }); });
No course practice sessions recorded yet. Sessions are tracked when students complete a Spelling Practice item in a course.
Could not load practice sessions.