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 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
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 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
View File
@@ -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 () => {
+1
View File
@@ -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>
+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]: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; }