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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-11 07:48:12 -04:00
1 parent 6b7baf7297
commit 5290dd80ca
3 files changed
+150

No files matched your search

+19
View File
@@ -406,6 +406,25 @@
<!-- Spelling (legacy word lists + weekly assignment) -->
<section id="panel-spelling" class="panel">
<h1>Spelling</h1>
<h2>Practice Activity</h2>
<div class="inline-form">
<label>Period <select id="practice-since">
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="">All time</option>
</select></label>
</div>
<div id="practice-activity-table"></div>
<div id="practice-modal" class="modal" hidden>
<div class="modal-box">
<h3 id="practice-modal-title">Practice Detail</h3>
<div id="practice-modal-body"></div>
<button id="practice-modal-close" class="secondary">Close</button>
</div>
</div>
<div class="panel-cols">
<div class="panel-col">
<h2>This week's list per kid</h2>
+78
View File
@@ -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 = `<p class="hint">No practice recorded yet.</p>`;
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
? `<span class="${pct >= appSettings.passingPct ? 'score-good' : 'score-bad'}">${d.correct_count}/${d.attempted} (${pct}%)</span>`
: '<span class="hint">—</span>';
return `<tr style="cursor:pointer" data-student="${s.id}" data-date="${d.day}">
<td>${dateLabel}</td>
<td class="hint">${startTime}–${endTime}</td>
<td>${esc(d.lists.join(', '))}</td>
<td>${scoreHtml}</td>
</tr>`;
}).join('');
return `<div class="results-student"><h3>${esc(s.emoji)} ${esc(s.name)}</h3>
<table class="results"><tr><th>Date</th><th>Time</th><th>Lists</th><th>First-try score</th></tr>${rows}</table></div>`;
}).join('');
container.querySelectorAll('[data-student][data-date]').forEach((row) =>
row.addEventListener('click', () => showPracticeDetail(row.dataset.student, row.dataset.date))
);
} catch (e) {
container.innerHTML = `<p class="hint">Could not load practice activity.</p>`;
}
}
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 = `<p class="hint">Loading…</p>`;
$('#practice-modal').hidden = false;
try {
const { words } = await api(`/api/spelling-activity/${studentId}/${date}`);
if (!words.length) {
$('#practice-modal-body').innerHTML = `<p class="hint">No attempts recorded.</p>`;
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 `<tr>
<td class="hint">${time}</td>
<td title="${w.mode === 'practice' ? 'First try' : 'Retype'}">${modeIcon}</td>
<td><strong>${esc(w.word)}</strong></td>
<td class="${correct ? 'score-good' : 'score-bad'}">${esc(w.typed)}</td>
<td>${correct ? '✓' : '✗'}</td>
</tr>`;
}).join('');
return `<h4 style="margin:0.75rem 0 0.25rem;font-size:.95rem">${esc(list)}</h4>
<table class="results"><tr><th>Time</th><th></th><th>Word</th><th>Typed</th><th></th></tr>${rows}</table>`;
}).join('');
} catch {
$('#practice-modal-body').innerHTML = `<p class="hint">Could not load detail.</p>`;
}
}
$('#practice-modal-close').addEventListener('click', () => { $('#practice-modal').hidden = true; });
$('#practice-since').addEventListener('change', loadPracticeActivity);
function renderAssignRows(students) {
const options = (sel) =>
`<option value="">— no list —</option>` +
+53
View File
@@ -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) => {