Track each spelling practice pass as a separate activity record
Each time a student completes a pass through a spelling_practice course item — initial round, missed-words drill, or a retry — a row is now written to submission_history so the parent sees individual attempts instead of a single day-grouped entry. - app.js: send sessionAttempted/sessionCorrect with /complete calls - server.js: write to submission_history on each spelling_practice completion; add GET /api/spelling-sessions endpoint - admin.html/js: new "Practice Sessions" table shows one row per pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb56b9b9e8
commit
2834738598
4 files changed
+94
-4
No files matched your search
+11
-1
@@ -407,7 +407,17 @@
|
||||
<section id="panel-spelling" class="panel">
|
||||
<h1>Spelling</h1>
|
||||
|
||||
<h2>Practice Activity</h2>
|
||||
<h2>Practice Sessions</h2>
|
||||
<div class="inline-form">
|
||||
<label>Period <select id="sessions-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-sessions-table"></div>
|
||||
|
||||
<h2>Practice Activity (word-by-word)</h2>
|
||||
<div class="inline-form">
|
||||
<label>Period <select id="practice-since">
|
||||
<option value="30">Last 30 days</option>
|
||||
|
||||
@@ -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 = `<p class="hint">No course practice sessions recorded yet. Sessions are tracked when students complete a Spelling Practice item in a course.</p>`;
|
||||
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
|
||||
? `<span class="${pct >= appSettings.passingPct ? 'score-good' : 'score-bad'}">${s.score}/${s.points_possible} (${pct}%)</span>`
|
||||
: '<span class="hint">—</span>';
|
||||
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 `<tr style="cursor:pointer" data-student="${s.student_id}" data-date="${s.completed_at.slice(0, 10)}">
|
||||
<td>${esc(s.emoji)} ${esc(s.student_name)}</td>
|
||||
<td>${dateLabel} <span class="hint">${timeLabel}</span></td>
|
||||
<td>${esc(s.course_name)}</td>
|
||||
<td>${esc(s.item_title)}</td>
|
||||
<td>${esc(s.list_name || '—')}</td>
|
||||
<td>${scoreHtml}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
container.innerHTML = `<table class="results">
|
||||
<tr><th>Student</th><th>Date</th><th>Course</th><th>Item</th><th>List</th><th>Score</th></tr>
|
||||
${rows}
|
||||
</table>`;
|
||||
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 sessions.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user