Add retakes, quiz templates, score history, due dates, CSV export, and prerequisites

- allow_retakes flag on items: quizzes, spelling tests, and assignments can all be retaken
- Quiz templates: save any quiz as a reusable template and load it into new items
- Score history: every quiz/test attempt logged to submission_history; click a gradebook score to see all attempts
- Due dates: optional due date per item, shown in course view and gradebook (overdue flagged with ⚠️)
- Gradebook CSV export: one-click download of any course gradebook
- Prerequisites: lock an item until a prior one is completed; locked items show 🔒 in course view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 13:22:58 -04:00
1 parent 23b24b5d21
commit 02909e1edc
6 files changed
+375 -39

No files matched your search

+23 -1
View File
@@ -113,6 +113,13 @@
</select>
</label>
<label>Title <input id="ie-title" placeholder="Item title"></label>
<div class="inline-form">
<label>Due date <input type="date" id="ie-due-date"></label>
<label class="checkbox-label"><input type="checkbox" id="ie-allow-retakes"> Allow retakes</label>
</div>
<label>Prerequisite item (must be done first)
<select id="ie-prereq"><option value="">None</option></select>
</label>
<!-- Scan a page: visible for lesson + quiz types -->
<div id="ie-scan-section" class="scan-card" hidden>
@@ -138,6 +145,11 @@
</div>
<div id="ie-field-quiz" class="ie-field">
<div class="inline-form" style="margin-bottom:.5rem">
<select id="ie-template-select"><option value="">— load from template —</option></select>
<button type="button" id="ie-load-template" class="secondary">Load</button>
<button type="button" id="ie-save-template" class="secondary">Save as template</button>
</div>
<div id="ie-questions"></div>
<button type="button" id="ie-add-question" class="secondary">+ Add question</button>
</div>
@@ -182,8 +194,18 @@
<!-- Gradebook -->
<section id="panel-gradebook" class="panel">
<h1>Gradebook</h1>
<label>Course <select id="gradebook-course"></select></label>
<div class="inline-form">
<label>Course <select id="gradebook-course"></select></label>
<button id="gradebook-csv-btn" class="secondary">⬇ Export CSV</button>
</div>
<div id="gradebook-table"></div>
<div id="history-modal" class="modal" hidden>
<div class="modal-box">
<h3 id="history-modal-title">Attempt History</h3>
<div id="history-modal-body"></div>
<button id="history-modal-close" class="secondary">Close</button>
</div>
</div>
</section>
<!-- Spelling (legacy word lists + weekly assignment) -->
+91 -5
View File
@@ -263,12 +263,31 @@ $('#add-unit-form').addEventListener('submit', async (e) => {
// ============================================================
let questionCount = 0;
let cachedQuizTemplates = [];
async function openItemEditor(unitId, itemId) {
[cachedLists, cachedDecks] = await Promise.all([api('/api/lists'), api('/api/decks')]);
let courseDetail;
[cachedLists, cachedDecks, cachedQuizTemplates, courseDetail] = await Promise.all([
api('/api/lists'),
api('/api/decks'),
api('/api/quiz-templates'),
api(`/api/admin/courses/${currentCourseId}`),
]);
$('#ie-list').innerHTML = cachedLists.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join('');
$('#ie-deck').innerHTML = cachedDecks.map((d) => `<option value="${d.id}">${esc(d.name)}</option>`).join('');
// Populate template select
$('#ie-template-select').innerHTML = `<option value="">— load from template —</option>` +
cachedQuizTemplates.map((t) => `<option value="${t.id}">${esc(t.name)} (${t.questionCount}q)</option>`).join('');
// Populate prereq select (all items in course except this one)
const allCourseItems = courseDetail.units.flatMap((u) => u.items);
$('#ie-prereq').innerHTML = `<option value="">None</option>` +
allCourseItems
.filter((it) => it.id !== Number(itemId))
.map((it) => `<option value="${it.id}">${esc(it.title)}</option>`)
.join('');
$('#ie-id').value = itemId || '';
$('#ie-unit-id').value = unitId;
$('#ie-error').hidden = true;
@@ -283,6 +302,9 @@ async function openItemEditor(unitId, itemId) {
$('#ie-body-lesson').value = item.body;
$('#ie-body-assignment').value = item.body;
$('#ie-points').value = item.points || 10;
$('#ie-due-date').value = item.due_date || '';
$('#ie-allow-retakes').checked = !!item.allow_retakes;
$('#ie-prereq').value = item.prereq_item_id || '';
if (item.type === 'spelling_practice' || item.type === 'spelling_test') $('#ie-list').value = item.ref_id;
if (item.type === 'flashcards') $('#ie-deck').value = item.ref_id;
if (item.type === 'quiz') (item.questions || []).forEach(addQuestionRow);
@@ -299,6 +321,9 @@ async function openItemEditor(unitId, itemId) {
$('#ie-body-lesson').value = '';
$('#ie-body-assignment').value = '';
$('#ie-points').value = 10;
$('#ie-due-date').value = '';
$('#ie-allow-retakes').checked = false;
$('#ie-prereq').value = '';
$('#ie-delete').hidden = true;
}
updateItemFieldVisibility();
@@ -423,10 +448,35 @@ function addQuestionRow(q = {}) {
}
$('#ie-add-question').addEventListener('click', () => addQuestionRow());
$('#ie-load-template').addEventListener('click', async () => {
const tid = $('#ie-template-select').value;
if (!tid) return msg('Pick a template first.');
const t = await api(`/api/quiz-templates/${tid}`);
$('#ie-questions').innerHTML = '';
questionCount = 0;
t.questions.forEach((q) => addQuestionRow({ ...q, correct_answer: q.correct_answer }));
msg(`Loaded "${t.name}" — ${t.questions.length} question(s).`);
});
$('#ie-save-template').addEventListener('click', async () => {
const itemId = $('#ie-id').value;
if (!itemId) return msg('Save the item first, then save as template.');
const name = prompt('Template name:');
if (!name) return;
try {
await api('/api/quiz-templates', { method: 'POST', body: { name, itemId: Number(itemId) } });
msg(`Template "${name}" saved.`);
cachedQuizTemplates = await api('/api/quiz-templates');
$('#ie-template-select').innerHTML = `<option value="">— load from template —</option>` +
cachedQuizTemplates.map((t) => `<option value="${t.id}">${esc(t.name)} (${t.questionCount}q)</option>`).join('');
} catch (err) { msg(err.message); }
});
$('#item-form').addEventListener('submit', async (e) => {
e.preventDefault();
const type = $('#ie-type').value;
const title = $('#ie-title').value.trim();
const prereqVal = $('#ie-prereq').value;
const body = {
unitId: Number($('#ie-unit-id').value),
type,
@@ -435,6 +485,9 @@ $('#item-form').addEventListener('submit', async (e) => {
points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0,
refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value)
: type === 'flashcards' ? Number($('#ie-deck').value) : null,
dueDate: $('#ie-due-date').value || null,
allowRetakes: $('#ie-allow-retakes').checked,
prereqItemId: prereqVal ? Number(prereqVal) : null,
};
if (type === 'quiz') {
body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({
@@ -660,29 +713,58 @@ async function loadGrading() {
// Gradebook
// ============================================================
let gradebookCourseId = null;
async function loadGradebookPanel() {
const courses = await api('/api/admin/courses');
$('#gradebook-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
if (courses.length) renderGradebook(courses[0].id);
}
$('#gradebook-course').addEventListener('change', () => renderGradebook($('#gradebook-course').value));
$('#gradebook-csv-btn').addEventListener('click', () => {
if (!gradebookCourseId) return;
window.location.href = `/api/gradebook/${gradebookCourseId}/csv`;
});
$('#history-modal-close').addEventListener('click', () => { $('#history-modal').hidden = true; });
async function showHistory(studentId, itemId, label) {
const { history } = await api(`/api/items/${itemId}/history?studentId=${studentId}`);
$('#history-modal-title').textContent = `Attempts: ${label}`;
if (!history.length) {
$('#history-modal-body').innerHTML = `<p class="hint">No recorded attempts yet.</p>`;
} else {
const rows = history.map((h) => {
const pct = h.points_possible ? Math.round((h.score / h.points_possible) * 100) : null;
const cls = pct !== null ? (pct >= 80 ? 'score-good' : 'score-bad') : '';
return `<tr><td>${new Date(h.completed_at + 'Z').toLocaleString()}</td><td class="${cls}">${h.score}/${h.points_possible}${pct !== null ? ` (${pct}%)` : ''}</td></tr>`;
}).join('');
$('#history-modal-body').innerHTML = `<table class="results"><tr><th>Date</th><th>Score</th></tr>${rows}</table>`;
}
$('#history-modal').hidden = false;
}
async function renderGradebook(courseId) {
gradebookCourseId = courseId || null;
if (!courseId) return ($('#gradebook-table').innerHTML = '');
const gb = await api(`/api/gradebook/${courseId}`);
if (gb.gradableItems.length === 0 || gb.students.length === 0) {
$('#gradebook-table').innerHTML = `<p class="hint">Need at least one enrolled kid and one graded item (assignment, quiz, or spelling test) to show a gradebook.</p>`;
return;
}
const header = gb.gradableItems.map((it) => `<th>${esc(it.title)}<br><small>${it.points} pts</small></th>`).join('');
const header = gb.gradableItems.map((it) => {
const due = it.due_date ? `<br><small class="hint">due ${it.due_date}</small>` : '';
return `<th>${esc(it.title)}<br><small>${it.points} pts</small>${due}</th>`;
}).join('');
const rows = gb.students
.map((s) => {
const cells = gb.gradableItems
.map((it) => {
const sc = s.scores[it.id];
if (!sc) return `<td class="hint">—</td>`;
if (sc.status !== 'graded') return `<td class="hint">⏳</td>`;
return `<td>${sc.score}/${sc.points_possible}</td>`;
const overdueClass = sc?.overdue ? ' overdue' : '';
if (!sc || (!sc.status && !sc.overdue)) return `<td class="hint">—</td>`;
if (sc.overdue && !sc.status) return `<td class="hint overdue" title="Overdue">⚠️</td>`;
if (sc.status !== 'graded') return `<td class="hint${overdueClass}">${sc.overdue ? '⚠️ ' : ''}⏳</td>`;
return `<td class="${sc.score / sc.points_possible >= 0.8 ? 'score-good' : 'score-bad'}${overdueClass}" style="cursor:pointer" data-history-student="${s.id}" data-history-item="${it.id}" data-history-label="${esc(it.title)} — ${esc(s.name)}">${sc.score}/${sc.points_possible}</td>`;
})
.join('');
const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
@@ -690,6 +772,10 @@ async function renderGradebook(courseId) {
})
.join('');
$('#gradebook-table').innerHTML = `<table class="results"><tr><th>Kid</th>${header}<th>Overall</th></tr>${rows}</table>`;
document.querySelectorAll('[data-history-student]').forEach((td) =>
td.addEventListener('click', () => showHistory(td.dataset.historyStudent, td.dataset.historyItem, td.dataset.historyLabel))
);
}
// ============================================================
+61 -13
View File
@@ -233,10 +233,18 @@ async function openCourse(courseId) {
<h2>${esc(u.name)}</h2>
${u.items
.map((it) => {
if (it.locked) {
return `<div class="today-row locked">
<span class="row-check">🔒</span>
<span class="row-title">${esc(it.title)}<small>${TYPE_LABEL[it.type]}</small></span>
<span class="row-badge hint">Complete previous item first</span>
</div>`;
}
const badge = statusBadge(it.type, it.status, it.score, it.points_possible, it.status !== 'not_started');
const due = it.due_date ? `<small class="due-date">Due ${it.due_date}</small>` : '';
return `<button class="today-row" data-item-id="${it.id}" data-item-type="${it.type}">
<span class="row-check">${it.status === 'not_started' ? TYPE_ICON[it.type] : '✅'}</span>
<span class="row-title">${esc(it.title)}<small>${TYPE_LABEL[it.type]}</small></span>
<span class="row-title">${esc(it.title)}${due}<small>${TYPE_LABEL[it.type]}</small></span>
<span class="row-badge">${badge}</span>
</button>`;
})
@@ -244,7 +252,7 @@ async function openCourse(courseId) {
</div>`
)
.join('');
document.querySelectorAll('#course-units .today-row').forEach((row) =>
document.querySelectorAll('#course-units .today-row:not(.locked)').forEach((row) =>
row.addEventListener('click', () => {
backTarget = () => openCourse(courseId);
openItem(Number(row.dataset.itemId), row.dataset.itemType);
@@ -258,10 +266,10 @@ async function openCourse(courseId) {
async function openItem(itemId, type) {
if (type === 'lesson') return openLesson(itemId);
if (type === 'assignment') return openAssignment(itemId);
if (type === 'quiz') return openQuiz(itemId);
if (type === 'quiz') return openQuiz(itemId, false);
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId });
if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId });
if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId, allowRetakes: item.allow_retakes });
if (type === 'flashcards') return startFlashcards({ deckId: item.deck.id, deckName: item.deck.name, itemId });
}
@@ -294,10 +302,19 @@ async function openAssignment(itemId) {
const btn = $('#assignment-done-btn');
const status = $('#assignment-status');
if (item.submission && item.submission.status === 'graded') {
btn.hidden = true;
status.hidden = false;
status.className = 'status-banner good';
status.textContent = `🌟 Graded: ${item.submission.score} / ${item.submission.points_possible}`;
if (item.allow_retakes) {
btn.hidden = false;
btn.textContent = '🔁 Retake';
btn.onclick = async () => {
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
openAssignment(itemId);
};
} else {
btn.hidden = true;
}
} else if (item.submission && item.submission.status === 'done') {
btn.hidden = true;
status.hidden = false;
@@ -305,6 +322,7 @@ async function openAssignment(itemId) {
status.textContent = `⏳ Turned in — waiting for a parent to grade it.`;
} else {
btn.hidden = false;
btn.textContent = 'Mark as done ✓';
status.hidden = true;
btn.onclick = async () => {
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
@@ -316,18 +334,34 @@ async function openAssignment(itemId) {
// ---------- quiz ----------
async function openQuiz(itemId) {
async function openQuiz(itemId, forceRetake = false) {
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
$('#quiz-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#quiz-title').textContent = item.title;
const graded = item.submission && item.submission.status === 'graded';
const graded = !forceRetake && item.submission && item.submission.status === 'graded';
const result = $('#quiz-result');
if (graded) {
result.hidden = false;
result.className = 'status-banner good';
result.textContent = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible}`;
$('#quiz-form').innerHTML = item.questions
const pct = item.submission.points_possible ? Math.round((item.submission.score / item.submission.points_possible) * 100) : 0;
result.innerHTML = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible} (${pct}%)`;
// Attempt history
const { history } = await api(`/api/items/${itemId}/history?studentId=${currentStudent.id}`);
let historyHtml = '';
if (history.length > 1) {
const histRows = history.map((h) => {
const hp = h.points_possible ? Math.round((h.score / h.points_possible) * 100) : 0;
return `<tr><td>${new Date(h.completed_at + 'Z').toLocaleString()}</td><td>${h.score}/${h.points_possible} (${hp}%)</td></tr>`;
}).join('');
historyHtml = `<details class="attempt-history"><summary>All attempts (${history.length})</summary><table>${histRows}</table></details>`;
}
let retakeBtn = '';
if (item.allow_retakes) retakeBtn = `<button id="quiz-retake-btn" class="check-btn secondary">🔁 Retake Quiz</button>`;
$('#quiz-form').innerHTML = historyHtml + retakeBtn + item.questions
.map((q) => {
const correct = normalizeAnswer(q.given) === normalizeAnswer(q.correct_answer);
return `<div class="quiz-question review">
@@ -337,6 +371,10 @@ async function openQuiz(itemId) {
</div>`;
})
.join('');
if (item.allow_retakes) {
$('#quiz-retake-btn').addEventListener('click', () => openQuiz(itemId, true));
}
} else {
result.hidden = true;
$('#quiz-form').innerHTML =
@@ -380,7 +418,7 @@ async function openQuiz(itemId) {
method: 'POST',
body: { studentId: currentStudent.id, answers, date: todayStr() },
});
openQuiz(itemId);
openQuiz(itemId, false);
};
}
show('quiz');
@@ -567,12 +605,12 @@ async function nextPracticeWord() {
// ---------- spelling: test ----------
const test = { list: null, words: [], i: 0, answers: [], itemId: null };
const test = { list: null, words: [], i: 0, answers: [], itemId: null, allowRetakes: false };
async function startTest({ listId, itemId } = {}) {
async function startTest({ listId, itemId, allowRetakes = false } = {}) {
const url = listId ? `/api/test/${currentStudent.id}?listId=${listId}` : `/api/test/${currentStudent.id}`;
const data = await api(url);
Object.assign(test, { list: data.list, words: data.words, i: 0, answers: [], itemId: itemId || null });
Object.assign(test, { list: data.list, words: data.words, i: 0, answers: [], itemId: itemId || null, allowRetakes: !!allowRetakes });
$('#test-card').hidden = false;
$('#test-done').hidden = true;
show('test');
@@ -617,13 +655,23 @@ async function finishTest() {
: `<li>❌ ${esc(g.word)} <small>(you wrote "${esc(g.typed)}")</small></li>`
)
.join('');
let retakeBtn = '';
if (test.itemId && test.allowRetakes) retakeBtn = `<button id="test-retake-btn" class="check-btn secondary">🔁 Retake Test</button>`;
$('#test-done').innerHTML = `
<div>⭐ Test complete!</div>
<div class="big-score">${result.score} / ${result.total}</div>
<ul>${rows}</ul>
${retakeBtn}
<button id="test-home">Back</button>`;
$('#test-done').hidden = false;
$('#test-home').addEventListener('click', () => backTarget());
if (test.itemId && test.allowRetakes) {
$('#test-retake-btn').addEventListener('click', () => {
openItem(test.itemId, 'spelling_test');
});
}
}
$('#btn-practice').addEventListener('click', () => {
+24
View File
@@ -392,3 +392,27 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
.scan-provider-box legend { font-weight: 600; padding: 0 0.3rem; }
.radio-label { display: block; margin: 0.3rem 0; cursor: pointer; }
.radio-label input { margin-right: 0.4rem; }
/* ---------- new features: locked items, due dates, overdue, history, modal ---------- */
.today-row.locked {
opacity: 0.55;
cursor: not-allowed;
background: var(--bg);
border-color: var(--muted);
}
.due-date { color: var(--muted); font-size: 0.78rem; display: block; }
td.overdue, .overdue { background: var(--bad-bg) !important; }
.attempt-history { margin: 0.75rem 0; font-size: 0.9rem; }
.attempt-history summary { cursor: pointer; color: var(--accent); }
.attempt-history table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.attempt-history td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; }
.modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal-box {
background: var(--card); border-radius: var(--radius);
padding: 1.5rem; max-width: 520px; width: 90%; max-height: 80vh; overflow-y: auto;
}
.modal-box h3 { margin-bottom: 1rem; }
.modal-box .secondary { margin-top: 1rem; }