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:
1 parent
183b43b452
commit
b048ebbd85
6 files changed
+110
-20
No files matched your search
@@ -284,6 +284,14 @@
|
||||
</div>
|
||||
<div id="gradebook-table"></div>
|
||||
<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="history-modal" class="modal" hidden>
|
||||
<div class="modal-box">
|
||||
|
||||
+25
-7
@@ -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 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}">
|
||||
<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}">
|
||||
${testBadges}
|
||||
${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>
|
||||
<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>
|
||||
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
|
||||
<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 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-grid').className = 'planner-grid planner-month';
|
||||
$('#planner-grid').innerHTML =
|
||||
`<div class="planner-month-header">${DAY_NAMES.map((d) => `<div>${d.slice(0,3)}</div>`).join('')}</div>` +
|
||||
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('');
|
||||
} else {
|
||||
const end = addDays(plannerWeekStart, 4);
|
||||
$('#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) }));
|
||||
$('#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();
|
||||
@@ -1215,7 +1227,8 @@ async function loadGradebookPanel() {
|
||||
}
|
||||
|
||||
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 active = students.filter((s) => s.tests.length > 0);
|
||||
if (!active.length) {
|
||||
@@ -1248,6 +1261,11 @@ $('#gradebook-csv-btn').addEventListener('click', () => {
|
||||
if (!gradebookCourseId) return;
|
||||
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; });
|
||||
|
||||
async function showHistory(studentId, itemId, label) {
|
||||
|
||||
+19
-3
@@ -238,10 +238,11 @@ function switchTab(name) {
|
||||
}
|
||||
|
||||
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/schedule/${id}?date=${todayStr()}`),
|
||||
api(`/api/courses/mine/${id}`),
|
||||
api(`/api/students/${id}/tests`),
|
||||
]);
|
||||
currentStudent = state.student;
|
||||
applyTheme(currentStudent.theme || 'blue');
|
||||
@@ -261,7 +262,7 @@ async function openKid(id, tab = 'today') {
|
||||
|
||||
renderToday(agenda.tasks);
|
||||
renderCourseCards(courses);
|
||||
renderSpellingTab(state);
|
||||
renderSpellingTab(state, recentTests);
|
||||
|
||||
show('kid');
|
||||
switchTab(tab);
|
||||
@@ -433,7 +434,7 @@ function renderCourseCards(courses) {
|
||||
let currentSpellingListId = null;
|
||||
let currentSpellingListName = '';
|
||||
|
||||
function renderSpellingTab(state) {
|
||||
function renderSpellingTab(state, tests = []) {
|
||||
let html;
|
||||
if (state.assignment) {
|
||||
const { mastered, total } = state.listProgress;
|
||||
@@ -452,6 +453,21 @@ function renderSpellingTab(state) {
|
||||
$('#kid-week').innerHTML = html;
|
||||
$('#btn-test').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 () => {
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<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>
|
||||
</div>
|
||||
<div id="kid-test-history"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -487,6 +487,7 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
|
||||
.planner-task[draggable] { cursor: grab; }
|
||||
.planner-task[draggable]:active { cursor: grabbing; }
|
||||
.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 {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
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-orange { background: #e8802a; }
|
||||
.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; }
|
||||
@@ -445,11 +445,6 @@ app.get('/api/overview', requirePin, (req, res) => {
|
||||
WHERE w.list_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 });
|
||||
});
|
||||
@@ -970,17 +965,61 @@ app.get('/api/gradebook/:courseId/csv', requirePin, (req, res) => {
|
||||
|
||||
// All standalone spelling test results grouped by student
|
||||
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();
|
||||
for (const s of students) {
|
||||
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);
|
||||
s.tests = cutoff
|
||||
? 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)
|
||||
: 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 });
|
||||
});
|
||||
|
||||
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)
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user