Add spelling test polish: date filter, CSV export, student history, planner badges

- Gradebook: period dropdown (all time / 30 / 90 days) and CSV export for
  Spelling Test History section
- Student portal spelling tab: shows last 10 tests as a Recent Tests list
- Planner: completed standalone spelling tests appear as read-only ⭐ badges
  on the day they were taken (week and month views)
- Removed dead s.tests query from /api/overview (now served by dedicated endpoints)
- New endpoints: GET /api/spelling-tests/csv, GET /api/students/:id/tests,
  GET /api/admin/students/:id/tests-range

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-27 14:26:47 -04:00
1 parent 183b43b452
commit b048ebbd85
6 files changed
+110 -20

No files matched your search

+8
View File
@@ -284,6 +284,14 @@
</div> </div>
<div id="gradebook-table"></div> <div id="gradebook-table"></div>
<h2>Spelling Test History</h2> <h2>Spelling Test History</h2>
<div class="inline-form">
<label>Period <select id="spelling-tests-since">
<option value="">All time</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select></label>
<button id="spelling-tests-csv-btn" class="secondary small">⬇ CSV</button>
</div>
<div id="spelling-tests-table"></div> <div id="spelling-tests-table"></div>
<div id="history-modal" class="modal" hidden> <div id="history-modal" class="modal" hidden>
<div class="modal-box"> <div class="modal-box">
+25 -7
View File
@@ -844,15 +844,21 @@ function attachPlannerEvents() {
); );
} }
function renderDayCell(day, tasks, compact = false) { function renderDayCell(day, tasks, compact = false, spellingTests = []) {
const dayTasks = tasks.filter((t) => t.date === day.date); const dayTasks = tasks.filter((t) => t.date === day.date);
const dayTests = spellingTests.filter((t) => t.date === day.date);
const testBadges = dayTests.map((t) => {
const pct = Math.round((t.score / t.total) * 100);
return `<div class="planner-task planner-test-badge" title="${esc(t.list)}">⭐ Spelling ${t.score}/${t.total} (${pct}%)</div>`;
}).join('');
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}" data-drop-date="${day.date}"> return `<div class="planner-day${compact ? ' planner-day-compact' : ''}" data-drop-date="${day.date}">
<h3>${compact ? day.name.slice(0,3) : day.name}<small>${compact ? day.date.slice(5) : day.date}</small></h3> <h3>${compact ? day.name.slice(0,3) : day.name}<small>${compact ? day.date.slice(5) : day.date}</small></h3>
<div class="planner-tasks" data-drop-date="${day.date}"> <div class="planner-tasks" data-drop-date="${day.date}">
${testBadges}
${dayTasks.map((t) => `<div class="planner-task ${t.done ? 'done' : ''}" draggable="true" data-task-id="${t.id}"> ${dayTasks.map((t) => `<div class="planner-task ${t.done ? 'done' : ''}" draggable="true" data-task-id="${t.id}">
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span> <span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
<button class="danger tiny" data-del-schedule="${t.id}">✕</button> <button class="danger tiny" data-del-schedule="${t.id}">✕</button>
</div>`).join('') || `<p class="hint tiny">—</p>`} </div>`).join('') || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
</div> </div>
<button class="secondary small" data-add-task="${day.date}">+ Add</button> <button class="secondary small" data-add-task="${day.date}">+ Add</button>
<div class="add-task-form" data-form-for="${day.date}" hidden></div> <div class="add-task-form" data-form-for="${day.date}" hidden></div>
@@ -878,22 +884,28 @@ async function renderPlanner() {
} }
const rangeStart = weeks[0][0].date; const rangeStart = weeks[0][0].date;
const rangeEnd = weeks[weeks.length - 1][4].date; const rangeEnd = weeks[weeks.length - 1][4].date;
const { tasks } = await api(`/api/schedule-range/${plannerStudentId}?start=${rangeStart}&end=${rangeEnd}`); const [{ tasks }, { tests: spellingTests }] = await Promise.all([
api(`/api/schedule-range/${plannerStudentId}?start=${rangeStart}&end=${rangeEnd}`),
api(`/api/admin/students/${plannerStudentId}/tests-range?from=${rangeStart}&to=${rangeEnd}`),
]);
$('#planner-week-label').textContent = monthLabel(plannerMonthRef); $('#planner-week-label').textContent = monthLabel(plannerMonthRef);
$('#planner-grid').className = 'planner-grid planner-month'; $('#planner-grid').className = 'planner-grid planner-month';
$('#planner-grid').innerHTML = $('#planner-grid').innerHTML =
`<div class="planner-month-header">${DAY_NAMES.map((d) => `<div>${d.slice(0,3)}</div>`).join('')}</div>` + `<div class="planner-month-header">${DAY_NAMES.map((d) => `<div>${d.slice(0,3)}</div>`).join('')}</div>` +
weeks.map((week) => weeks.map((week) =>
`<div class="planner-month-row">${week.map((day) => renderDayCell(day, tasks, true)).join('')}</div>` `<div class="planner-month-row">${week.map((day) => renderDayCell(day, tasks, true, spellingTests)).join('')}</div>`
).join(''); ).join('');
} else { } else {
const end = addDays(plannerWeekStart, 4); const end = addDays(plannerWeekStart, 4);
$('#planner-week-label').textContent = `${plannerWeekStart} → ${end}`; $('#planner-week-label').textContent = `${plannerWeekStart} → ${end}`;
const { tasks } = await api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`); const [{ tasks }, { tests: spellingTests }] = await Promise.all([
api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`),
api(`/api/admin/students/${plannerStudentId}/tests-range?from=${plannerWeekStart}&to=${end}`),
]);
const days = DAY_NAMES.map((name, i) => ({ name, date: addDays(plannerWeekStart, i) })); const days = DAY_NAMES.map((name, i) => ({ name, date: addDays(plannerWeekStart, i) }));
$('#planner-grid').className = 'planner-grid'; $('#planner-grid').className = 'planner-grid';
$('#planner-grid').innerHTML = days.map((day) => renderDayCell(day, tasks)).join(''); $('#planner-grid').innerHTML = days.map((day) => renderDayCell(day, tasks, false, spellingTests)).join('');
} }
attachPlannerEvents(); attachPlannerEvents();
@@ -1215,7 +1227,8 @@ async function loadGradebookPanel() {
} }
async function loadSpellingTests() { async function loadSpellingTests() {
const { students } = await api('/api/spelling-tests'); const since = $('#spelling-tests-since').value;
const { students } = await api(since ? `/api/spelling-tests?since=${since}` : '/api/spelling-tests');
const container = $('#spelling-tests-table'); const container = $('#spelling-tests-table');
const active = students.filter((s) => s.tests.length > 0); const active = students.filter((s) => s.tests.length > 0);
if (!active.length) { if (!active.length) {
@@ -1248,6 +1261,11 @@ $('#gradebook-csv-btn').addEventListener('click', () => {
if (!gradebookCourseId) return; if (!gradebookCourseId) return;
window.location.href = `/api/gradebook/${gradebookCourseId}/csv`; window.location.href = `/api/gradebook/${gradebookCourseId}/csv`;
}); });
$('#spelling-tests-since').addEventListener('change', loadSpellingTests);
$('#spelling-tests-csv-btn').addEventListener('click', () => {
const since = $('#spelling-tests-since').value;
window.location.href = `/api/spelling-tests/csv${since ? '?since=' + since : ''}`;
});
$('#history-modal-close').addEventListener('click', () => { $('#history-modal').hidden = true; }); $('#history-modal-close').addEventListener('click', () => { $('#history-modal').hidden = true; });
async function showHistory(studentId, itemId, label) { async function showHistory(studentId, itemId, label) {
+19 -3
View File
@@ -238,10 +238,11 @@ function switchTab(name) {
} }
async function openKid(id, tab = 'today') { async function openKid(id, tab = 'today') {
const [state, agenda, courses] = await Promise.all([ const [state, agenda, courses, { tests: recentTests }] = await Promise.all([
api(`/api/state/${id}`), api(`/api/state/${id}`),
api(`/api/schedule/${id}?date=${todayStr()}`), api(`/api/schedule/${id}?date=${todayStr()}`),
api(`/api/courses/mine/${id}`), api(`/api/courses/mine/${id}`),
api(`/api/students/${id}/tests`),
]); ]);
currentStudent = state.student; currentStudent = state.student;
applyTheme(currentStudent.theme || 'blue'); applyTheme(currentStudent.theme || 'blue');
@@ -261,7 +262,7 @@ async function openKid(id, tab = 'today') {
renderToday(agenda.tasks); renderToday(agenda.tasks);
renderCourseCards(courses); renderCourseCards(courses);
renderSpellingTab(state); renderSpellingTab(state, recentTests);
show('kid'); show('kid');
switchTab(tab); switchTab(tab);
@@ -433,7 +434,7 @@ function renderCourseCards(courses) {
let currentSpellingListId = null; let currentSpellingListId = null;
let currentSpellingListName = ''; let currentSpellingListName = '';
function renderSpellingTab(state) { function renderSpellingTab(state, tests = []) {
let html; let html;
if (state.assignment) { if (state.assignment) {
const { mastered, total } = state.listProgress; const { mastered, total } = state.listProgress;
@@ -452,6 +453,21 @@ function renderSpellingTab(state) {
$('#kid-week').innerHTML = html; $('#kid-week').innerHTML = html;
$('#btn-test').disabled = !state.assignment; $('#btn-test').disabled = !state.assignment;
$('#btn-print-list').disabled = !state.assignment; $('#btn-print-list').disabled = !state.assignment;
const histEl = $('#kid-test-history');
if (!tests.length) {
histEl.innerHTML = '';
} else {
const rows = tests.map((t) => {
const pct = Math.round((t.score / t.total) * 100);
return `<div class="test-history-row">
<span class="thr-date">${new Date(t.at + 'Z').toLocaleDateString()}</span>
<span class="thr-list">${esc(t.list)}</span>
<span class="thr-score ${pct >= 80 ? 'score-good' : 'score-bad'}">${t.score}/${t.total} (${pct}%)</span>
</div>`;
}).join('');
histEl.innerHTML = `<h3 class="thr-heading">Recent Tests</h3>${rows}`;
}
} }
$('#btn-print-list').addEventListener('click', async () => { $('#btn-print-list').addEventListener('click', async () => {
+1
View File
@@ -55,6 +55,7 @@
<button id="btn-test" class="big-btn test">⭐ Friday Test</button> <button id="btn-test" class="big-btn test">⭐ Friday Test</button>
<button id="btn-print-list" class="big-btn print-list">🖨 Print my words</button> <button id="btn-print-list" class="big-btn print-list">🖨 Print my words</button>
</div> </div>
<div id="kid-test-history"></div>
</div> </div>
</section> </section>
+8
View File
@@ -487,6 +487,7 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
.planner-task[draggable] { cursor: grab; } .planner-task[draggable] { cursor: grab; }
.planner-task[draggable]:active { cursor: grabbing; } .planner-task[draggable]:active { cursor: grabbing; }
.planner-task.dragging { opacity: 0.35; } .planner-task.dragging { opacity: 0.35; }
.planner-test-badge { background: color-mix(in srgb, var(--accent) 18%, var(--card)); border-left: 3px solid var(--accent); font-size: 0.78rem; cursor: default; pointer-events: none; }
.planner-tasks.drop-target { .planner-tasks.drop-target {
background: color-mix(in srgb, var(--accent) 12%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent);
border-radius: 8px; border-radius: 8px;
@@ -724,3 +725,10 @@ body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; }
.theme-swatch.theme-purple { background: #7c5cbf; } .theme-swatch.theme-purple { background: #7c5cbf; }
.theme-swatch.theme-orange { background: #e8802a; } .theme-swatch.theme-orange { background: #e8802a; }
.theme-swatch.theme-pink { background: #d45d8a; } .theme-swatch.theme-pink { background: #d45d8a; }
/* ---------- student portal: test history ---------- */
.thr-heading { margin: 1.5rem 0 0.5rem; font-size: 1rem; }
.test-history-row { display: flex; gap: 0.75rem; align-items: baseline; padding: 0.35rem 0; border-bottom: 1px solid var(--muted); font-size: 0.9rem; flex-wrap: wrap; }
.thr-date { color: var(--muted); min-width: 6rem; flex-shrink: 0; }
.thr-list { flex: 1; min-width: 8rem; }
.thr-score { margin-left: auto; font-weight: 600; white-space: nowrap; }
+49 -10
View File
@@ -445,11 +445,6 @@ app.get('/api/overview', requirePin, (req, res) => {
WHERE w.list_id = ? WHERE w.list_id = ?
`).get(s.id, s.assignment.id); `).get(s.id, s.assignment.id);
} }
s.tests = db.prepare(`
SELECT t.id, t.score, t.total, t.at, l.name AS list
FROM tests t JOIN lists l ON l.id = t.list_id
WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20
`).all(s.id);
} }
res.json({ students }); res.json({ students });
}); });
@@ -970,17 +965,61 @@ app.get('/api/gradebook/:courseId/csv', requirePin, (req, res) => {
// All standalone spelling test results grouped by student // All standalone spelling test results grouped by student
app.get('/api/spelling-tests', requirePin, (req, res) => { app.get('/api/spelling-tests', requirePin, (req, res) => {
const days = parseInt(req.query.since) || null;
const cutoff = days ? new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) : null;
const students = db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all(); const students = db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all();
for (const s of students) { for (const s of students) {
s.tests = db.prepare(` s.tests = cutoff
SELECT t.id, t.score, t.total, t.at, l.name AS list ? db.prepare(`SELECT t.id, t.score, t.total, t.at, l.name AS list FROM tests t JOIN lists l ON l.id = t.list_id WHERE t.student_id = ? AND date(t.at) >= ? ORDER BY t.at DESC LIMIT 20`).all(s.id, cutoff)
FROM tests t JOIN lists l ON l.id = t.list_id : db.prepare(`SELECT t.id, t.score, t.total, t.at, l.name AS list FROM tests t JOIN lists l ON l.id = t.list_id WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20`).all(s.id);
WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 20
`).all(s.id);
} }
res.json({ students }); res.json({ students });
}); });
app.get('/api/spelling-tests/csv', requirePin, (req, res) => {
const days = parseInt(req.query.since) || null;
const cutoff = days ? new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) : null;
const rows = db.prepare(`
SELECT s.emoji, s.name AS student, l.name AS list, t.score, t.total, t.at
FROM tests t JOIN students s ON s.id = t.student_id JOIN lists l ON l.id = t.list_id
${cutoff ? 'WHERE date(t.at) >= ?' : ''} ORDER BY t.at DESC
`).all(...(cutoff ? [cutoff] : []));
const q = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const header = 'Date,Student,List,Score,Total,%';
const data = rows.map((r) => [
q(new Date(r.at + 'Z').toLocaleDateString()),
q(`${r.emoji} ${r.student}`),
q(r.list), r.score, r.total,
Math.round((r.score / r.total) * 100) + '%',
].join(',')).join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="spelling-test-history.csv"');
res.send(header + '\n' + data);
});
// Student-facing: recent tests for the spelling tab (no PIN — student portal)
app.get('/api/students/:id/tests', (req, res) => {
const tests = db.prepare(`
SELECT t.id, t.score, t.total, t.at, l.name AS list
FROM tests t JOIN lists l ON l.id = t.list_id
WHERE t.student_id = ? ORDER BY t.at DESC LIMIT 10
`).all(req.params.id);
res.json({ tests });
});
// Admin: tests within a date range for a single student (used by planner)
app.get('/api/admin/students/:id/tests-range', requirePin, (req, res) => {
const { from, to } = req.query;
if (!from || !to) return res.status(400).json({ error: 'from and to required' });
const tests = db.prepare(`
SELECT t.id, t.score, t.total, t.at, date(t.at) AS date, l.name AS list
FROM tests t JOIN lists l ON l.id = t.list_id
WHERE t.student_id = ? AND date(t.at) BETWEEN ? AND ?
ORDER BY t.at
`).all(req.params.id, from, to);
res.json({ tests });
});
// Full evidence for a submission (photo can be large, not included in queue list) // Full evidence for a submission (photo can be large, not included in queue list)
app.get('/api/grading-queue/:id/evidence', requirePin, (req, res) => { app.get('/api/grading-queue/:id/evidence', requirePin, (req, res) => {
const row = db.prepare(`SELECT evidence_notes, evidence_photo FROM submissions WHERE id = ?`).get(req.params.id); const row = db.prepare(`SELECT evidence_notes, evidence_photo FROM submissions WHERE id = ?`).get(req.params.id);