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:
1 parent
23b24b5d21
commit
02909e1edc
6 files changed
+375
-39
No files matched your search
@@ -181,8 +181,40 @@ db.exec(`
|
|||||||
sort INTEGER NOT NULL DEFAULT 0
|
sort INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_schedule_day ON schedule(student_id, date);
|
CREATE INDEX IF NOT EXISTS idx_schedule_day ON schedule(student_id, date);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quiz_templates (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS quiz_template_questions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
template_id INTEGER NOT NULL REFERENCES quiz_templates(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
choices TEXT NOT NULL DEFAULT '[]',
|
||||||
|
correct_answer TEXT NOT NULL,
|
||||||
|
points INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS submission_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
score REAL,
|
||||||
|
points_possible REAL,
|
||||||
|
answers TEXT,
|
||||||
|
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
function tryAlter(sql) {
|
||||||
|
try { db.exec(sql); } catch { /* column already exists */ }
|
||||||
|
}
|
||||||
|
tryAlter(`ALTER TABLE items ADD COLUMN due_date TEXT`);
|
||||||
|
tryAlter(`ALTER TABLE items ADD COLUMN allow_retakes INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
tryAlter(`ALTER TABLE items ADD COLUMN prereq_item_id INTEGER`);
|
||||||
|
|
||||||
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
|
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
|
||||||
|
|
||||||
// Default parent PIN on first run
|
// Default parent PIN on first run
|
||||||
|
|||||||
+23
-1
@@ -113,6 +113,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Title <input id="ie-title" placeholder="Item title"></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 -->
|
<!-- Scan a page: visible for lesson + quiz types -->
|
||||||
<div id="ie-scan-section" class="scan-card" hidden>
|
<div id="ie-scan-section" class="scan-card" hidden>
|
||||||
@@ -138,6 +145,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="ie-field-quiz" class="ie-field">
|
<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>
|
<div id="ie-questions"></div>
|
||||||
<button type="button" id="ie-add-question" class="secondary">+ Add question</button>
|
<button type="button" id="ie-add-question" class="secondary">+ Add question</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,8 +194,18 @@
|
|||||||
<!-- Gradebook -->
|
<!-- Gradebook -->
|
||||||
<section id="panel-gradebook" class="panel">
|
<section id="panel-gradebook" class="panel">
|
||||||
<h1>Gradebook</h1>
|
<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="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>
|
</section>
|
||||||
|
|
||||||
<!-- Spelling (legacy word lists + weekly assignment) -->
|
<!-- Spelling (legacy word lists + weekly assignment) -->
|
||||||
|
|||||||
+91
-5
@@ -263,12 +263,31 @@ $('#add-unit-form').addEventListener('submit', async (e) => {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
let questionCount = 0;
|
let questionCount = 0;
|
||||||
|
let cachedQuizTemplates = [];
|
||||||
|
|
||||||
async function openItemEditor(unitId, itemId) {
|
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-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('');
|
$('#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-id').value = itemId || '';
|
||||||
$('#ie-unit-id').value = unitId;
|
$('#ie-unit-id').value = unitId;
|
||||||
$('#ie-error').hidden = true;
|
$('#ie-error').hidden = true;
|
||||||
@@ -283,6 +302,9 @@ async function openItemEditor(unitId, itemId) {
|
|||||||
$('#ie-body-lesson').value = item.body;
|
$('#ie-body-lesson').value = item.body;
|
||||||
$('#ie-body-assignment').value = item.body;
|
$('#ie-body-assignment').value = item.body;
|
||||||
$('#ie-points').value = item.points || 10;
|
$('#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 === '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 === 'flashcards') $('#ie-deck').value = item.ref_id;
|
||||||
if (item.type === 'quiz') (item.questions || []).forEach(addQuestionRow);
|
if (item.type === 'quiz') (item.questions || []).forEach(addQuestionRow);
|
||||||
@@ -299,6 +321,9 @@ async function openItemEditor(unitId, itemId) {
|
|||||||
$('#ie-body-lesson').value = '';
|
$('#ie-body-lesson').value = '';
|
||||||
$('#ie-body-assignment').value = '';
|
$('#ie-body-assignment').value = '';
|
||||||
$('#ie-points').value = 10;
|
$('#ie-points').value = 10;
|
||||||
|
$('#ie-due-date').value = '';
|
||||||
|
$('#ie-allow-retakes').checked = false;
|
||||||
|
$('#ie-prereq').value = '';
|
||||||
$('#ie-delete').hidden = true;
|
$('#ie-delete').hidden = true;
|
||||||
}
|
}
|
||||||
updateItemFieldVisibility();
|
updateItemFieldVisibility();
|
||||||
@@ -423,10 +448,35 @@ function addQuestionRow(q = {}) {
|
|||||||
}
|
}
|
||||||
$('#ie-add-question').addEventListener('click', () => addQuestionRow());
|
$('#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) => {
|
$('#item-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const type = $('#ie-type').value;
|
const type = $('#ie-type').value;
|
||||||
const title = $('#ie-title').value.trim();
|
const title = $('#ie-title').value.trim();
|
||||||
|
const prereqVal = $('#ie-prereq').value;
|
||||||
const body = {
|
const body = {
|
||||||
unitId: Number($('#ie-unit-id').value),
|
unitId: Number($('#ie-unit-id').value),
|
||||||
type,
|
type,
|
||||||
@@ -435,6 +485,9 @@ $('#item-form').addEventListener('submit', async (e) => {
|
|||||||
points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0,
|
points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0,
|
||||||
refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value)
|
refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value)
|
||||||
: type === 'flashcards' ? Number($('#ie-deck').value) : null,
|
: 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') {
|
if (type === 'quiz') {
|
||||||
body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({
|
body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({
|
||||||
@@ -660,29 +713,58 @@ async function loadGrading() {
|
|||||||
// Gradebook
|
// Gradebook
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
let gradebookCourseId = null;
|
||||||
|
|
||||||
async function loadGradebookPanel() {
|
async function loadGradebookPanel() {
|
||||||
const courses = await api('/api/admin/courses');
|
const courses = await api('/api/admin/courses');
|
||||||
$('#gradebook-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
|
$('#gradebook-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
|
||||||
if (courses.length) renderGradebook(courses[0].id);
|
if (courses.length) renderGradebook(courses[0].id);
|
||||||
}
|
}
|
||||||
$('#gradebook-course').addEventListener('change', () => renderGradebook($('#gradebook-course').value));
|
$('#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) {
|
async function renderGradebook(courseId) {
|
||||||
|
gradebookCourseId = courseId || null;
|
||||||
if (!courseId) return ($('#gradebook-table').innerHTML = '');
|
if (!courseId) return ($('#gradebook-table').innerHTML = '');
|
||||||
const gb = await api(`/api/gradebook/${courseId}`);
|
const gb = await api(`/api/gradebook/${courseId}`);
|
||||||
if (gb.gradableItems.length === 0 || gb.students.length === 0) {
|
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>`;
|
$('#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;
|
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
|
const rows = gb.students
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const cells = gb.gradableItems
|
const cells = gb.gradableItems
|
||||||
.map((it) => {
|
.map((it) => {
|
||||||
const sc = s.scores[it.id];
|
const sc = s.scores[it.id];
|
||||||
if (!sc) return `<td class="hint">—</td>`;
|
const overdueClass = sc?.overdue ? ' overdue' : '';
|
||||||
if (sc.status !== 'graded') return `<td class="hint">⏳</td>`;
|
if (!sc || (!sc.status && !sc.overdue)) return `<td class="hint">—</td>`;
|
||||||
return `<td>${sc.score}/${sc.points_possible}</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('');
|
.join('');
|
||||||
const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
|
const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
|
||||||
@@ -690,6 +772,10 @@ async function renderGradebook(courseId) {
|
|||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
$('#gradebook-table').innerHTML = `<table class="results"><tr><th>Kid</th>${header}<th>Overall</th></tr>${rows}</table>`;
|
$('#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
@@ -233,10 +233,18 @@ async function openCourse(courseId) {
|
|||||||
<h2>${esc(u.name)}</h2>
|
<h2>${esc(u.name)}</h2>
|
||||||
${u.items
|
${u.items
|
||||||
.map((it) => {
|
.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 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}">
|
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-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>
|
<span class="row-badge">${badge}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
})
|
})
|
||||||
@@ -244,7 +252,7 @@ async function openCourse(courseId) {
|
|||||||
</div>`
|
</div>`
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
document.querySelectorAll('#course-units .today-row').forEach((row) =>
|
document.querySelectorAll('#course-units .today-row:not(.locked)').forEach((row) =>
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', () => {
|
||||||
backTarget = () => openCourse(courseId);
|
backTarget = () => openCourse(courseId);
|
||||||
openItem(Number(row.dataset.itemId), row.dataset.itemType);
|
openItem(Number(row.dataset.itemId), row.dataset.itemType);
|
||||||
@@ -258,10 +266,10 @@ async function openCourse(courseId) {
|
|||||||
async function openItem(itemId, type) {
|
async function openItem(itemId, type) {
|
||||||
if (type === 'lesson') return openLesson(itemId);
|
if (type === 'lesson') return openLesson(itemId);
|
||||||
if (type === 'assignment') return openAssignment(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}`);
|
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
||||||
if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId });
|
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 });
|
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 btn = $('#assignment-done-btn');
|
||||||
const status = $('#assignment-status');
|
const status = $('#assignment-status');
|
||||||
if (item.submission && item.submission.status === 'graded') {
|
if (item.submission && item.submission.status === 'graded') {
|
||||||
btn.hidden = true;
|
|
||||||
status.hidden = false;
|
status.hidden = false;
|
||||||
status.className = 'status-banner good';
|
status.className = 'status-banner good';
|
||||||
status.textContent = `🌟 Graded: ${item.submission.score} / ${item.submission.points_possible}`;
|
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') {
|
} else if (item.submission && item.submission.status === 'done') {
|
||||||
btn.hidden = true;
|
btn.hidden = true;
|
||||||
status.hidden = false;
|
status.hidden = false;
|
||||||
@@ -305,6 +322,7 @@ async function openAssignment(itemId) {
|
|||||||
status.textContent = `⏳ Turned in — waiting for a parent to grade it.`;
|
status.textContent = `⏳ Turned in — waiting for a parent to grade it.`;
|
||||||
} else {
|
} else {
|
||||||
btn.hidden = false;
|
btn.hidden = false;
|
||||||
|
btn.textContent = 'Mark as done ✓';
|
||||||
status.hidden = true;
|
status.hidden = true;
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
|
||||||
@@ -316,18 +334,34 @@ async function openAssignment(itemId) {
|
|||||||
|
|
||||||
// ---------- quiz ----------
|
// ---------- quiz ----------
|
||||||
|
|
||||||
async function openQuiz(itemId) {
|
async function openQuiz(itemId, forceRetake = false) {
|
||||||
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
|
||||||
$('#quiz-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
|
$('#quiz-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
|
||||||
$('#quiz-title').textContent = item.title;
|
$('#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');
|
const result = $('#quiz-result');
|
||||||
|
|
||||||
if (graded) {
|
if (graded) {
|
||||||
result.hidden = false;
|
result.hidden = false;
|
||||||
result.className = 'status-banner good';
|
result.className = 'status-banner good';
|
||||||
result.textContent = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible}`;
|
const pct = item.submission.points_possible ? Math.round((item.submission.score / item.submission.points_possible) * 100) : 0;
|
||||||
$('#quiz-form').innerHTML = item.questions
|
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) => {
|
.map((q) => {
|
||||||
const correct = normalizeAnswer(q.given) === normalizeAnswer(q.correct_answer);
|
const correct = normalizeAnswer(q.given) === normalizeAnswer(q.correct_answer);
|
||||||
return `<div class="quiz-question review">
|
return `<div class="quiz-question review">
|
||||||
@@ -337,6 +371,10 @@ async function openQuiz(itemId) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
if (item.allow_retakes) {
|
||||||
|
$('#quiz-retake-btn').addEventListener('click', () => openQuiz(itemId, true));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result.hidden = true;
|
result.hidden = true;
|
||||||
$('#quiz-form').innerHTML =
|
$('#quiz-form').innerHTML =
|
||||||
@@ -380,7 +418,7 @@ async function openQuiz(itemId) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { studentId: currentStudent.id, answers, date: todayStr() },
|
body: { studentId: currentStudent.id, answers, date: todayStr() },
|
||||||
});
|
});
|
||||||
openQuiz(itemId);
|
openQuiz(itemId, false);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
show('quiz');
|
show('quiz');
|
||||||
@@ -567,12 +605,12 @@ async function nextPracticeWord() {
|
|||||||
|
|
||||||
// ---------- spelling: test ----------
|
// ---------- 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 url = listId ? `/api/test/${currentStudent.id}?listId=${listId}` : `/api/test/${currentStudent.id}`;
|
||||||
const data = await api(url);
|
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-card').hidden = false;
|
||||||
$('#test-done').hidden = true;
|
$('#test-done').hidden = true;
|
||||||
show('test');
|
show('test');
|
||||||
@@ -617,13 +655,23 @@ async function finishTest() {
|
|||||||
: `<li>❌ ${esc(g.word)} <small>(you wrote "${esc(g.typed)}")</small></li>`
|
: `<li>❌ ${esc(g.word)} <small>(you wrote "${esc(g.typed)}")</small></li>`
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
let retakeBtn = '';
|
||||||
|
if (test.itemId && test.allowRetakes) retakeBtn = `<button id="test-retake-btn" class="check-btn secondary">🔁 Retake Test</button>`;
|
||||||
|
|
||||||
$('#test-done').innerHTML = `
|
$('#test-done').innerHTML = `
|
||||||
<div>⭐ Test complete!</div>
|
<div>⭐ Test complete!</div>
|
||||||
<div class="big-score">${result.score} / ${result.total}</div>
|
<div class="big-score">${result.score} / ${result.total}</div>
|
||||||
<ul>${rows}</ul>
|
<ul>${rows}</ul>
|
||||||
|
${retakeBtn}
|
||||||
<button id="test-home">Back</button>`;
|
<button id="test-home">Back</button>`;
|
||||||
$('#test-done').hidden = false;
|
$('#test-done').hidden = false;
|
||||||
$('#test-home').addEventListener('click', () => backTarget());
|
$('#test-home').addEventListener('click', () => backTarget());
|
||||||
|
if (test.itemId && test.allowRetakes) {
|
||||||
|
$('#test-retake-btn').addEventListener('click', () => {
|
||||||
|
openItem(test.itemId, 'spelling_test');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#btn-practice').addEventListener('click', () => {
|
$('#btn-practice').addEventListener('click', () => {
|
||||||
|
|||||||
@@ -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; }
|
.scan-provider-box legend { font-weight: 600; padding: 0 0.3rem; }
|
||||||
.radio-label { display: block; margin: 0.3rem 0; cursor: pointer; }
|
.radio-label { display: block; margin: 0.3rem 0; cursor: pointer; }
|
||||||
.radio-label input { margin-right: 0.4rem; }
|
.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; }
|
||||||
@@ -204,6 +204,8 @@ app.post('/api/test/:studentId', (req, res) => {
|
|||||||
status = 'graded', score = excluded.score, points_possible = excluded.points_possible,
|
status = 'graded', score = excluded.score, points_possible = excluded.points_possible,
|
||||||
completed_at = datetime('now'), graded_at = datetime('now')
|
completed_at = datetime('now'), graded_at = datetime('now')
|
||||||
`).run(studentId, itemId, score, graded.length);
|
`).run(studentId, itemId, score, graded.length);
|
||||||
|
db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible) VALUES (?, ?, ?, ?)`)
|
||||||
|
.run(studentId, itemId, score, graded.length);
|
||||||
markScheduleDone.run(studentId, itemId, isDateStr(date) ? date : today());
|
markScheduleDone.run(studentId, itemId, isDateStr(date) ? date : today());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,14 +455,14 @@ app.get('/api/admin/courses/:id', requirePin, (req, res) => {
|
|||||||
const course = db.prepare(`SELECT id, name, subject, color, archived FROM courses WHERE id = ?`).get(req.params.id);
|
const course = db.prepare(`SELECT id, name, subject, color, archived FROM courses WHERE id = ?`).get(req.params.id);
|
||||||
if (!course) return res.status(404).json({ error: 'No such course' });
|
if (!course) return res.status(404).json({ error: 'No such course' });
|
||||||
const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id);
|
const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id);
|
||||||
const itemStmt = db.prepare(`SELECT id, type, title, points, ref_id, sort FROM items WHERE unit_id = ? ORDER BY sort, id`);
|
const itemStmt = db.prepare(`SELECT id, type, title, points, ref_id, sort, due_date, allow_retakes, prereq_item_id FROM items WHERE unit_id = ? ORDER BY sort, id`);
|
||||||
for (const u of units) u.items = itemStmt.all(u.id);
|
for (const u of units) u.items = itemStmt.all(u.id);
|
||||||
res.json({ ...course, units });
|
res.json({ ...course, units });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Full item detail for the admin item editor (quiz correct answers always included)
|
// Full item detail for the admin item editor (quiz correct answers always included)
|
||||||
app.get('/api/admin/items/:id', requirePin, (req, res) => {
|
app.get('/api/admin/items/:id', requirePin, (req, res) => {
|
||||||
const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id FROM items WHERE id = ?`).get(req.params.id);
|
const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id, due_date, allow_retakes, prereq_item_id FROM items WHERE id = ?`).get(req.params.id);
|
||||||
if (!item) return res.status(404).json({ error: 'No such item' });
|
if (!item) return res.status(404).json({ error: 'No such item' });
|
||||||
if (item.type === 'quiz') {
|
if (item.type === 'quiz') {
|
||||||
item.questions = db.prepare(`
|
item.questions = db.prepare(`
|
||||||
@@ -541,13 +543,14 @@ function saveQuizQuestions(itemId, questions) {
|
|||||||
app.post('/api/items', requirePin, (req, res) => {
|
app.post('/api/items', requirePin, (req, res) => {
|
||||||
const err = validateItemBody(req.body);
|
const err = validateItemBody(req.body);
|
||||||
if (err) return res.status(400).json({ error: err });
|
if (err) return res.status(400).json({ error: err });
|
||||||
const { unitId, type, title, body, points, refId } = req.body;
|
const { unitId, type, title, body, points, refId, dueDate, allowRetakes, prereqItemId } = req.body;
|
||||||
const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM items WHERE unit_id = ?`).get(unitId).n;
|
const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM items WHERE unit_id = ?`).get(unitId).n;
|
||||||
const id = db.prepare(`
|
const id = db.prepare(`
|
||||||
INSERT INTO items (unit_id, type, title, body, points, ref_id, sort) VALUES (?, ?, ?, ?, ?, ?, ?)
|
INSERT INTO items (unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, prereq_item_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(unitId, type, String(title).trim(), String(body || ''), Number(points) || 0,
|
`).run(unitId, type, String(title).trim(), String(body || ''), Number(points) || 0,
|
||||||
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
||||||
sort).lastInsertRowid;
|
sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null).lastInsertRowid;
|
||||||
if (type === 'quiz') {
|
if (type === 'quiz') {
|
||||||
saveQuizQuestions(id, req.body.questions);
|
saveQuizQuestions(id, req.body.questions);
|
||||||
const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0);
|
const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0);
|
||||||
@@ -562,14 +565,14 @@ app.put('/api/items/:id', requirePin, (req, res) => {
|
|||||||
if (!db.prepare(`SELECT id FROM items WHERE id = ?`).get(req.params.id)) {
|
if (!db.prepare(`SELECT id FROM items WHERE id = ?`).get(req.params.id)) {
|
||||||
return res.status(404).json({ error: 'No such item' });
|
return res.status(404).json({ error: 'No such item' });
|
||||||
}
|
}
|
||||||
const { type, title, body, points, refId } = req.body;
|
const { type, title, body, points, refId, dueDate, allowRetakes, prereqItemId } = req.body;
|
||||||
const finalPoints = type === 'quiz'
|
const finalPoints = type === 'quiz'
|
||||||
? req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0)
|
? req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0)
|
||||||
: (Number(points) || 0);
|
: (Number(points) || 0);
|
||||||
db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ? WHERE id = ?`)
|
db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ?, due_date = ?, allow_retakes = ?, prereq_item_id = ? WHERE id = ?`)
|
||||||
.run(type, String(title).trim(), String(body || ''), finalPoints,
|
.run(type, String(title).trim(), String(body || ''), finalPoints,
|
||||||
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
|
||||||
req.params.id);
|
dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null, req.params.id);
|
||||||
if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions);
|
if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -613,7 +616,7 @@ app.get('/api/courses/:id/detail', (req, res) => {
|
|||||||
|
|
||||||
const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id);
|
const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id);
|
||||||
const itemStmt = db.prepare(`
|
const itemStmt = db.prepare(`
|
||||||
SELECT i.id, i.type, i.title, i.points, i.sort,
|
SELECT i.id, i.type, i.title, i.points, i.sort, i.due_date, i.allow_retakes, i.prereq_item_id,
|
||||||
s.status, s.score, s.points_possible
|
s.status, s.score, s.points_possible
|
||||||
FROM items i
|
FROM items i
|
||||||
LEFT JOIN submissions s ON s.item_id = i.id AND s.student_id = ?
|
LEFT JOIN submissions s ON s.item_id = i.id AND s.student_id = ?
|
||||||
@@ -622,6 +625,14 @@ app.get('/api/courses/:id/detail', (req, res) => {
|
|||||||
for (const u of units) {
|
for (const u of units) {
|
||||||
u.items = itemStmt.all(studentId, u.id).map((it) => ({ ...it, status: it.status || 'not_started' }));
|
u.items = itemStmt.all(studentId, u.id).map((it) => ({ ...it, status: it.status || 'not_started' }));
|
||||||
}
|
}
|
||||||
|
// Build submission map for prereq locking
|
||||||
|
const subMap = {};
|
||||||
|
for (const u of units) for (const it of u.items) subMap[it.id] = it.status;
|
||||||
|
for (const u of units) {
|
||||||
|
for (const it of u.items) {
|
||||||
|
it.locked = it.prereq_item_id ? (subMap[it.prereq_item_id] === 'not_started' || !subMap[it.prereq_item_id]) : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
res.json({ ...course, units });
|
res.json({ ...course, units });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -629,12 +640,20 @@ app.get('/api/courses/:id/detail', (req, res) => {
|
|||||||
app.get('/api/items/:id', (req, res) => {
|
app.get('/api/items/:id', (req, res) => {
|
||||||
const studentId = req.query.studentId;
|
const studentId = req.query.studentId;
|
||||||
const item = db.prepare(`
|
const item = db.prepare(`
|
||||||
SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, u.name AS unit_name, u.course_id, c.name AS course_name
|
SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, i.due_date, i.allow_retakes, i.prereq_item_id,
|
||||||
|
u.name AS unit_name, u.course_id, c.name AS course_name
|
||||||
FROM items i JOIN units u ON u.id = i.unit_id JOIN courses c ON c.id = u.course_id
|
FROM items i JOIN units u ON u.id = i.unit_id JOIN courses c ON c.id = u.course_id
|
||||||
WHERE i.id = ?
|
WHERE i.id = ?
|
||||||
`).get(req.params.id);
|
`).get(req.params.id);
|
||||||
if (!item) return res.status(404).json({ error: 'No such item' });
|
if (!item) return res.status(404).json({ error: 'No such item' });
|
||||||
|
|
||||||
|
if (item.prereq_item_id && studentId) {
|
||||||
|
const prereqSub = db.prepare(`SELECT status FROM submissions WHERE student_id = ? AND item_id = ?`).get(studentId, item.prereq_item_id);
|
||||||
|
item.locked = !prereqSub;
|
||||||
|
} else {
|
||||||
|
item.locked = false;
|
||||||
|
}
|
||||||
|
|
||||||
item.submission = db.prepare(`
|
item.submission = db.prepare(`
|
||||||
SELECT status, score, points_possible, answers, completed_at, graded_at
|
SELECT status, score, points_possible, answers, completed_at, graded_at
|
||||||
FROM submissions WHERE student_id = ? AND item_id = ?
|
FROM submissions WHERE student_id = ? AND item_id = ?
|
||||||
@@ -660,18 +679,28 @@ app.get('/api/items/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark a lesson viewed, an assignment done, or a practice/flashcards session finished.
|
// Mark a lesson viewed, an assignment done, or a practice/flashcards session finished.
|
||||||
// Never downgrades an already-graded submission (quizzes, spelling tests).
|
// Never downgrades an already-graded submission unless allow_retakes is set.
|
||||||
app.post('/api/items/:id/complete', (req, res) => {
|
app.post('/api/items/:id/complete', (req, res) => {
|
||||||
const { studentId, date } = req.body;
|
const { studentId, date } = req.body;
|
||||||
const item = db.prepare(`SELECT id, points FROM items WHERE id = ?`).get(req.params.id);
|
const item = db.prepare(`SELECT id, points, allow_retakes FROM items WHERE id = ?`).get(req.params.id);
|
||||||
if (!item) return res.status(404).json({ error: 'No such item' });
|
if (!item) return res.status(404).json({ error: 'No such item' });
|
||||||
|
|
||||||
db.prepare(`
|
if (item.allow_retakes) {
|
||||||
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at)
|
db.prepare(`
|
||||||
VALUES (?, ?, 'done', ?, datetime('now'))
|
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at)
|
||||||
ON CONFLICT (student_id, item_id) DO UPDATE SET completed_at = datetime('now')
|
VALUES (?, ?, 'done', ?, datetime('now'))
|
||||||
WHERE submissions.status != 'graded'
|
ON CONFLICT (student_id, item_id) DO UPDATE SET
|
||||||
`).run(studentId, req.params.id, item.points || null);
|
status = 'done', points_possible = excluded.points_possible,
|
||||||
|
completed_at = datetime('now'), score = NULL, graded_at = NULL
|
||||||
|
`).run(studentId, req.params.id, item.points || null);
|
||||||
|
} else {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at)
|
||||||
|
VALUES (?, ?, 'done', ?, datetime('now'))
|
||||||
|
ON CONFLICT (student_id, item_id) DO UPDATE SET completed_at = datetime('now')
|
||||||
|
WHERE submissions.status != 'graded'
|
||||||
|
`).run(studentId, req.params.id, item.points || null);
|
||||||
|
}
|
||||||
|
|
||||||
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
|
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
@@ -701,6 +730,9 @@ app.post('/api/items/:id/quiz-submit', (req, res) => {
|
|||||||
answers = excluded.answers, completed_at = datetime('now'), graded_at = datetime('now')
|
answers = excluded.answers, completed_at = datetime('now'), graded_at = datetime('now')
|
||||||
`).run(studentId, req.params.id, earned, possible, JSON.stringify(record));
|
`).run(studentId, req.params.id, earned, possible, JSON.stringify(record));
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible, answers) VALUES (?, ?, ?, ?, ?)`)
|
||||||
|
.run(studentId, req.params.id, earned, possible, JSON.stringify(record));
|
||||||
|
|
||||||
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
|
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
|
||||||
res.json({ score: earned, total: possible });
|
res.json({ score: earned, total: possible });
|
||||||
});
|
});
|
||||||
@@ -738,7 +770,7 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
|
|||||||
if (!course) return res.status(404).json({ error: 'No such course' });
|
if (!course) return res.status(404).json({ error: 'No such course' });
|
||||||
|
|
||||||
const gradableItems = db.prepare(`
|
const gradableItems = db.prepare(`
|
||||||
SELECT i.id, i.title, i.type, i.points
|
SELECT i.id, i.title, i.type, i.points, i.due_date
|
||||||
FROM items i JOIN units u ON u.id = i.unit_id
|
FROM items i JOIN units u ON u.id = i.unit_id
|
||||||
WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')})
|
WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')})
|
||||||
ORDER BY u.sort, i.sort, i.id
|
ORDER BY u.sort, i.sort, i.id
|
||||||
@@ -750,12 +782,14 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
|
|||||||
`).all(req.params.courseId);
|
`).all(req.params.courseId);
|
||||||
|
|
||||||
const scoreStmt = db.prepare(`SELECT score, points_possible, status FROM submissions WHERE student_id = ? AND item_id = ?`);
|
const scoreStmt = db.prepare(`SELECT score, points_possible, status FROM submissions WHERE student_id = ? AND item_id = ?`);
|
||||||
|
const now = today();
|
||||||
for (const s of students) {
|
for (const s of students) {
|
||||||
s.scores = {};
|
s.scores = {};
|
||||||
let earned = 0, possible = 0;
|
let earned = 0, possible = 0;
|
||||||
for (const item of gradableItems) {
|
for (const item of gradableItems) {
|
||||||
const row = scoreStmt.get(s.id, item.id);
|
const row = scoreStmt.get(s.id, item.id);
|
||||||
s.scores[item.id] = row || null;
|
const overdue = !!(item.due_date && item.due_date < now && (!row || row.status !== 'graded'));
|
||||||
|
s.scores[item.id] = row ? { ...row, overdue } : (overdue ? { overdue: true } : null);
|
||||||
if (row && row.status === 'graded' && row.points_possible) {
|
if (row && row.status === 'graded' && row.points_possible) {
|
||||||
earned += row.score;
|
earned += row.score;
|
||||||
possible += row.points_possible;
|
possible += row.points_possible;
|
||||||
@@ -767,6 +801,96 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
|
|||||||
res.json({ course, gradableItems, students });
|
res.json({ course, gradableItems, students });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CSV export for the gradebook
|
||||||
|
app.get('/api/gradebook/:courseId/csv', requirePin, (req, res) => {
|
||||||
|
const course = db.prepare(`SELECT id, name FROM courses WHERE id = ?`).get(req.params.courseId);
|
||||||
|
if (!course) return res.status(404).json({ error: 'No such course' });
|
||||||
|
|
||||||
|
const gradableItems = db.prepare(`
|
||||||
|
SELECT i.id, i.title, i.type, i.points
|
||||||
|
FROM items i JOIN units u ON u.id = i.unit_id
|
||||||
|
WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')})
|
||||||
|
ORDER BY u.sort, i.sort, i.id
|
||||||
|
`).all(req.params.courseId, ...GRADABLE_TYPES);
|
||||||
|
|
||||||
|
const students = db.prepare(`
|
||||||
|
SELECT s.id, s.name FROM students s
|
||||||
|
JOIN enrollments e ON e.student_id = s.id WHERE e.course_id = ? ORDER BY s.name
|
||||||
|
`).all(req.params.courseId);
|
||||||
|
|
||||||
|
const scoreStmt = db.prepare(`SELECT score, points_possible, status FROM submissions WHERE student_id = ? AND item_id = ?`);
|
||||||
|
const csvEsc = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`;
|
||||||
|
const header = ['Student', ...gradableItems.map((i) => csvEsc(`${i.title} (${i.points}pts)`)), 'Overall %'];
|
||||||
|
const rows = students.map((s) => {
|
||||||
|
let earned = 0, possible = 0;
|
||||||
|
const cells = gradableItems.map((item) => {
|
||||||
|
const row = scoreStmt.get(s.id, item.id);
|
||||||
|
if (!row || row.status !== 'graded') return '—';
|
||||||
|
if (row.points_possible) { earned += row.score; possible += row.points_possible; }
|
||||||
|
return `${row.score}/${row.points_possible}`;
|
||||||
|
});
|
||||||
|
const pct = possible > 0 ? Math.round((earned / possible) * 100) + '%' : '—';
|
||||||
|
return [csvEsc(s.name), ...cells, pct];
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `gradebook-${course.name.replace(/[^a-z0-9]/gi, '-')}.csv`;
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send([header, ...rows].map((r) => r.join(',')).join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score history for a student on one item
|
||||||
|
app.get('/api/items/:id/history', (req, res) => {
|
||||||
|
const { studentId } = req.query;
|
||||||
|
const history = db.prepare(`
|
||||||
|
SELECT score, points_possible, completed_at FROM submission_history
|
||||||
|
WHERE student_id = ? AND item_id = ? ORDER BY completed_at DESC
|
||||||
|
`).all(studentId, req.params.id);
|
||||||
|
res.json({ history });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- quiz templates ----------
|
||||||
|
|
||||||
|
app.get('/api/quiz-templates', requirePin, (req, res) => {
|
||||||
|
const templates = db.prepare(`
|
||||||
|
SELECT t.id, t.name, COUNT(q.id) AS questionCount
|
||||||
|
FROM quiz_templates t LEFT JOIN quiz_template_questions q ON q.template_id = t.id
|
||||||
|
GROUP BY t.id ORDER BY t.name
|
||||||
|
`).all();
|
||||||
|
res.json(templates);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/quiz-templates/:id', requirePin, (req, res) => {
|
||||||
|
const t = db.prepare(`SELECT id, name FROM quiz_templates WHERE id = ?`).get(req.params.id);
|
||||||
|
if (!t) return res.status(404).json({ error: 'No such template' });
|
||||||
|
t.questions = db.prepare(`
|
||||||
|
SELECT type, prompt, choices, correct_answer, points FROM quiz_template_questions
|
||||||
|
WHERE template_id = ? ORDER BY sort, id
|
||||||
|
`).all(req.params.id).map((q) => ({ ...q, choices: JSON.parse(q.choices) }));
|
||||||
|
res.json(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/quiz-templates', requirePin, (req, res) => {
|
||||||
|
const { name, itemId } = req.body;
|
||||||
|
if (!String(name || '').trim()) return res.status(400).json({ error: 'Name required' });
|
||||||
|
let qs = req.body.questions;
|
||||||
|
if (itemId) {
|
||||||
|
qs = db.prepare(`SELECT type, prompt, choices, correct_answer, points, sort FROM quiz_questions WHERE item_id = ? ORDER BY sort, id`).all(itemId);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(qs) || qs.length === 0) return res.status(400).json({ error: 'No questions' });
|
||||||
|
const id = db.prepare(`INSERT INTO quiz_templates (name) VALUES (?)`).run(String(name).trim()).lastInsertRowid;
|
||||||
|
const ins = db.prepare(`INSERT INTO quiz_template_questions (template_id, type, prompt, choices, correct_answer, points, sort) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
qs.forEach((q, i) => ins.run(id, q.type, String(q.prompt).trim(),
|
||||||
|
typeof q.choices === 'string' ? q.choices : JSON.stringify(q.choices || []),
|
||||||
|
String(q.correct_answer ?? q.correctAnswer ?? '').trim(), Number(q.points) || 1, i));
|
||||||
|
res.json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/quiz-templates/:id', requirePin, (req, res) => {
|
||||||
|
db.prepare(`DELETE FROM quiz_templates WHERE id = ?`).run(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- daily agenda / scheduling ----------
|
// ---------- daily agenda / scheduling ----------
|
||||||
|
|
||||||
// Kid's checklist for one day
|
// Kid's checklist for one day
|
||||||
|
|||||||
Reference in new issue
Block a user