Add evidence submission, 3-state offline tasks, parent comments, and retake policies

- Evidence on assignments: photo upload + notes + student self-note (needs help / not sure)
- Evidence required/optional/none flag per item; grading queue shows evidence with lightbox thumbnail
- Parent comment field when grading; shown to student on graded items
- Offline tasks: 3-state status (not started → in progress → done) with inline evidence form
- Retake policy per item: latest / highest / average — gradebook computes effective score accordingly
- Grading queue expanded to include lessons and practices (not just assignments) when evidence present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-14 13:33:42 -04:00
1 parent 02909e1edc
commit a7de24b1da
7 files changed
+351 -58

No files matched your search

+9
View File
@@ -214,6 +214,15 @@ function tryAlter(sql) {
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`);
tryAlter(`ALTER TABLE items ADD COLUMN evidence_mode TEXT NOT NULL DEFAULT 'none'`);
tryAlter(`ALTER TABLE items ADD COLUMN retake_policy TEXT NOT NULL DEFAULT 'latest'`);
tryAlter(`ALTER TABLE submissions ADD COLUMN evidence_notes TEXT`);
tryAlter(`ALTER TABLE submissions ADD COLUMN evidence_photo TEXT`);
tryAlter(`ALTER TABLE submissions ADD COLUMN student_note TEXT`);
tryAlter(`ALTER TABLE submissions ADD COLUMN parent_comment TEXT`);
tryAlter(`ALTER TABLE schedule ADD COLUMN status TEXT NOT NULL DEFAULT 'not_started'`);
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_notes TEXT`);
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_photo TEXT`);
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
+24
View File
@@ -120,6 +120,22 @@
<label>Prerequisite item (must be done first)
<select id="ie-prereq"><option value="">None</option></select>
</label>
<div class="inline-form">
<label>Evidence from student
<select id="ie-evidence-mode">
<option value="none">None</option>
<option value="optional">Optional</option>
<option value="required">Required</option>
</select>
</label>
<label>Retake score policy
<select id="ie-retake-policy">
<option value="latest">Latest attempt</option>
<option value="highest">Highest score</option>
<option value="average">Average of all</option>
</select>
</label>
</div>
<!-- Scan a page: visible for lesson + quiz types -->
<div id="ie-scan-section" class="scan-card" hidden>
@@ -189,6 +205,14 @@
<h1>Grading Queue</h1>
<div id="grading-rows"></div>
<p id="grading-empty" class="hint" hidden>Nothing waiting to be graded 🎉</p>
<!-- Evidence lightbox -->
<div id="evidence-lightbox" class="modal" hidden>
<div class="modal-box">
<h3>Evidence</h3>
<div id="evidence-lightbox-body"></div>
<button id="evidence-lightbox-close" class="secondary">Close</button>
</div>
</div>
</section>
<!-- Gradebook -->
+61 -14
View File
@@ -305,6 +305,8 @@ async function openItemEditor(unitId, itemId) {
$('#ie-due-date').value = item.due_date || '';
$('#ie-allow-retakes').checked = !!item.allow_retakes;
$('#ie-prereq').value = item.prereq_item_id || '';
$('#ie-evidence-mode').value = item.evidence_mode || 'none';
$('#ie-retake-policy').value = item.retake_policy || 'latest';
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);
@@ -324,6 +326,8 @@ async function openItemEditor(unitId, itemId) {
$('#ie-due-date').value = '';
$('#ie-allow-retakes').checked = false;
$('#ie-prereq').value = '';
$('#ie-evidence-mode').value = 'none';
$('#ie-retake-policy').value = 'latest';
$('#ie-delete').hidden = true;
}
updateItemFieldVisibility();
@@ -488,6 +492,8 @@ $('#item-form').addEventListener('submit', async (e) => {
dueDate: $('#ie-due-date').value || null,
allowRetakes: $('#ie-allow-retakes').checked,
prereqItemId: prereqVal ? Number(prereqVal) : null,
evidenceMode: $('#ie-evidence-mode').value,
retakePolicy: $('#ie-retake-policy').value,
};
if (type === 'quiz') {
body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({
@@ -683,30 +689,71 @@ async function printWeekReport(studentId, start) {
// Grading queue
// ============================================================
$('#evidence-lightbox-close').addEventListener('click', () => { $('#evidence-lightbox').hidden = true; });
function showEvidenceLightbox(notes, photo) {
let html = '';
if (photo) html += `<img src="${photo}" style="max-width:100%;border-radius:8px;margin-bottom:.75rem">`;
if (notes) html += `<p style="white-space:pre-wrap">${esc(notes)}</p>`;
$('#evidence-lightbox-body').innerHTML = html || '<p class="hint">No evidence content.</p>';
$('#evidence-lightbox').hidden = false;
}
const STUDENT_NOTE_LABEL = { needs_help: '🙋 Needs help', not_sure: '🤔 Not sure' };
async function loadGrading() {
const rows = await api('/api/grading-queue');
$('#grading-empty').hidden = rows.length > 0;
$('#grading-rows').innerHTML = rows
.map(
(r) => `<div class="item-row">
<span>${esc(r.emoji)}</span>
<strong class="grow">${esc(r.studentName)}</strong>
<span>${esc(r.itemTitle)}</span>
<span class="hint">${esc(r.courseName)} · ${esc(r.unitName)}</span>
<input type="number" class="grade-input" min="0" max="${r.points_possible}" placeholder="/ ${r.points_possible}" data-sub="${r.submissionId}">
<button data-save-grade="${r.submissionId}">Save</button>
</div>`
)
.map((r) => {
const hasEvidence = r.evidence_notes || r.evidence_photo;
const noteTag = r.student_note ? `<span class="badge-tag">${STUDENT_NOTE_LABEL[r.student_note] || r.student_note}</span>` : '';
const evidenceBtn = hasEvidence
? `<button class="secondary small" data-evidence-notes="${esc(r.evidence_notes || '')}" data-evidence-photo="${r.evidence_photo ? 'yes' : ''}" data-sub-id="${r.submissionId}">📎 Evidence</button>`
: '';
return `<div class="grading-card">
<div class="grading-header">
<span>${esc(r.emoji)}</span>
<strong>${esc(r.studentName)}</strong>
<span class="grow">${esc(r.itemTitle)}</span>
<span class="hint">${esc(r.courseName)} · ${esc(r.unitName)}</span>
${noteTag}
</div>
${evidenceBtn}
<div class="grading-footer">
<input type="number" class="grade-input" min="0" max="${r.points_possible}" placeholder="Score / ${r.points_possible || '?'}" data-sub="${r.submissionId}">
<input type="text" class="comment-input" placeholder="Parent comment (optional)" data-comment-sub="${r.submissionId}">
<button data-save-grade="${r.submissionId}">Save grade</button>
</div>
</div>`;
})
.join('');
document.querySelectorAll('[data-save-grade]').forEach((btn) =>
document.querySelectorAll('[data-evidence-notes]').forEach((btn) => {
btn.addEventListener('click', async () => {
// photo may be truncated in the attribute — fetch full evidence via API
const subId = btn.dataset.subId;
let photo = null;
if (btn.dataset.evidencePhoto === 'yes') {
const full = await fetch(`/api/grading-queue/${subId}/evidence`, { headers: { 'x-pin': parentPin } });
if (full.ok) { const d = await full.json(); photo = d.evidence_photo; }
}
showEvidenceLightbox(btn.dataset.evidenceNotes || null, photo);
});
});
document.querySelectorAll('[data-save-grade]').forEach((btn) => {
btn.addEventListener('click', async () => {
const input = document.querySelector(`.grade-input[data-sub="${btn.dataset.saveGrade}"]`);
const commentInput = document.querySelector(`.comment-input[data-comment-sub="${btn.dataset.saveGrade}"]`);
if (input.value === '') return;
await api(`/api/submissions/${btn.dataset.saveGrade}/grade`, { method: 'PUT', body: { score: Number(input.value) } });
await api(`/api/submissions/${btn.dataset.saveGrade}/grade`, {
method: 'PUT',
body: { score: Number(input.value), parentComment: commentInput?.value || '' },
});
loadGrading();
})
);
});
});
}
// ============================================================
+133 -12
View File
@@ -54,6 +54,46 @@ document.querySelectorAll('[data-back]').forEach((btn) =>
})
);
// ---------- evidence photo helpers ----------
let evidencePhotoBase64 = null;
function compressPhoto(file, maxDim = 1200, quality = 0.75) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Could not read file'));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('Could not read image'));
img.onload = () => {
let { width, height } = img;
if (width > maxDim || height > maxDim) {
const scale = maxDim / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
$('#assignment-photo-btn').addEventListener('click', () => $('#assignment-photo-input').click());
$('#assignment-photo-input').addEventListener('change', async () => {
const file = $('#assignment-photo-input').files[0];
if (!file) return;
try {
const dataUrl = await compressPhoto(file);
evidencePhotoBase64 = dataUrl;
$('#assignment-photo-status').textContent = '✅ Photo ready';
} catch { $('#assignment-photo-status').textContent = '❌ Could not load photo'; }
});
// ---------- text to speech (spelling module) ----------
function speak(text, rate = 0.85) {
@@ -145,16 +185,38 @@ async function openKid(id, tab = 'today') {
switchTab(tab);
}
const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' };
function renderToday(tasks) {
$('#today-empty').hidden = tasks.length > 0;
$('#today-list').innerHTML = tasks
.map((t) => {
if (!t.itemId) {
return `<button class="today-row offline" data-schedule-id="${t.id}">
<span class="row-check">${t.done ? '✅' : '⬜'}</span>
<span class="row-title">${esc(t.offlineTitle)}</span>
<span class="row-badge">📌 Offline</span>
</button>`;
const st = t.offlineStatus || (t.done ? 'done' : 'not_started');
return `<div class="today-row-offline" data-schedule-id="${t.id}">
<div class="offline-main">
<span class="row-check">${OFFLINE_STATUS_ICON[st] || '⬜'}</span>
<span class="row-title">${esc(t.offlineTitle)}</span>
<span class="row-badge">📌${t.hasEvidence ? ' 📎' : ''}</span>
</div>
<div class="offline-controls">
<button class="offline-status-btn ${st === 'not_started' ? 'active' : ''}" data-sid="${t.id}" data-status="not_started">Not started</button>
<button class="offline-status-btn ${st === 'in_progress' ? 'active' : ''}" data-sid="${t.id}" data-status="in_progress">In progress</button>
<button class="offline-status-btn ${st === 'done' ? 'active' : ''}" data-sid="${t.id}" data-status="done">Done ✓</button>
</div>
<details class="offline-evidence-form">
<summary>📎 Add notes / photo</summary>
<div class="evidence-inner">
<textarea class="offline-notes" placeholder="Notes…" rows="2"></textarea>
<div class="evidence-photo-row">
<button type="button" class="secondary small offline-photo-btn">📷 Photo</button>
<input type="file" class="offline-photo-input" accept="image/*" capture="environment" hidden>
<span class="offline-photo-status hint"></span>
</div>
<button type="button" class="secondary small offline-evidence-save" data-sid="${t.id}">Save evidence</button>
</div>
</details>
</div>`;
}
const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done);
return `<button class="today-row" data-item-id="${t.itemId}" data-item-type="${t.type}">
@@ -165,13 +227,43 @@ function renderToday(tasks) {
})
.join('');
document.querySelectorAll('.today-row.offline').forEach((row) =>
row.addEventListener('click', async () => {
const done = row.querySelector('.row-check').textContent.trim() !== '✅';
await api(`/api/schedule/${row.dataset.scheduleId}/done`, { method: 'POST', body: { done } });
// Offline status buttons
document.querySelectorAll('.offline-status-btn').forEach((btn) =>
btn.addEventListener('click', async () => {
await api(`/api/schedule/${btn.dataset.sid}/done`, { method: 'POST', body: { status: btn.dataset.status } });
openKid(currentStudent.id, 'today');
})
);
// Offline evidence photo picker
document.querySelectorAll('.today-row-offline').forEach((row) => {
const photoBtn = row.querySelector('.offline-photo-btn');
const photoInput = row.querySelector('.offline-photo-input');
const photoStatus = row.querySelector('.offline-photo-status');
photoBtn.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', async () => {
const file = photoInput.files[0];
if (!file) return;
try {
photoInput._dataUrl = await compressPhoto(file);
photoStatus.textContent = '✅ Ready';
} catch { photoStatus.textContent = '❌ Error'; }
});
});
// Offline evidence save
document.querySelectorAll('.offline-evidence-save').forEach((btn) =>
btn.addEventListener('click', async () => {
const row = btn.closest('.today-row-offline');
const notes = row.querySelector('.offline-notes').value || null;
const photoInput = row.querySelector('.offline-photo-input');
const photo = photoInput._dataUrl || null;
await api(`/api/schedule/${btn.dataset.sid}/done`, { method: 'POST', body: { evidenceNotes: notes, evidencePhoto: photo } });
btn.textContent = '✅ Saved';
setTimeout(() => openKid(currentStudent.id, 'today'), 600);
})
);
document.querySelectorAll('.today-row:not(.offline)').forEach((row) =>
row.addEventListener('click', () => {
backTarget = () => openKid(currentStudent.id, 'today');
@@ -293,6 +385,12 @@ async function openLesson(itemId) {
// ---------- assignment ----------
async function openAssignment(itemId) {
evidencePhotoBase64 = null;
$('#assignment-photo-status').textContent = '';
$('#assignment-photo-input').value = '';
$('#assignment-evidence-notes').value = '';
$('#assignment-student-note').value = '';
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
$('#assignment-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#assignment-title').textContent = item.title;
@@ -301,15 +399,30 @@ async function openAssignment(itemId) {
const btn = $('#assignment-done-btn');
const status = $('#assignment-status');
const commentEl = $('#assignment-comment');
const evidenceSection = $('#assignment-evidence-section');
const needsEvidence = item.evidence_mode === 'required' || item.evidence_mode === 'optional';
// Show parent comment if graded
if (item.submission?.parent_comment) {
commentEl.hidden = false;
commentEl.innerHTML = `<strong>Parent feedback:</strong> ${esc(item.submission.parent_comment)}`;
} else {
commentEl.hidden = true;
}
if (item.submission && item.submission.status === 'graded') {
status.hidden = false;
status.className = 'status-banner good';
status.textContent = `🌟 Graded: ${item.submission.score} / ${item.submission.points_possible}`;
evidenceSection.hidden = !needsEvidence || !item.allow_retakes;
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() } });
const body = { studentId: currentStudent.id, date: todayStr() };
if (needsEvidence) { body.evidenceNotes = $('#assignment-evidence-notes').value || null; body.evidencePhoto = evidencePhotoBase64; body.studentNote = $('#assignment-student-note').value || null; }
await api(`/api/items/${itemId}/complete`, { method: 'POST', body });
openAssignment(itemId);
};
} else {
@@ -317,15 +430,23 @@ async function openAssignment(itemId) {
}
} else if (item.submission && item.submission.status === 'done') {
btn.hidden = true;
evidenceSection.hidden = true;
status.hidden = false;
status.className = 'status-banner';
status.textContent = `⏳ Turned in — waiting for a parent to grade it.`;
} else {
btn.hidden = false;
btn.textContent = 'Mark as done ✓';
btn.textContent = item.evidence_mode === 'required' ? 'Submit with evidence ✓' : 'Mark as done ✓';
status.hidden = true;
evidenceSection.hidden = !needsEvidence;
btn.onclick = async () => {
await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } });
if (item.evidence_mode === 'required' && !$('#assignment-evidence-notes').value && !evidencePhotoBase64) {
status.hidden = false; status.className = 'status-banner bad'; status.textContent = 'Evidence required — add notes or a photo first.';
return;
}
const body = { studentId: currentStudent.id, date: todayStr() };
if (needsEvidence) { body.evidenceNotes = $('#assignment-evidence-notes').value || null; body.evidencePhoto = evidencePhotoBase64; body.studentNote = $('#assignment-student-note').value || null; }
await api(`/api/items/${itemId}/complete`, { method: 'POST', body });
openAssignment(itemId);
};
}
+21
View File
@@ -73,8 +73,29 @@
<h1 id="assignment-title"></h1>
<p id="assignment-points" class="points-label"></p>
<div id="assignment-body" class="lesson-body"></div>
<div id="assignment-evidence-section" hidden>
<details id="assignment-evidence-details" class="evidence-form">
<summary>📎 Add evidence (photo or notes)</summary>
<div class="evidence-inner">
<textarea id="assignment-evidence-notes" placeholder="Notes about your work…" rows="3"></textarea>
<div class="evidence-photo-row">
<button type="button" id="assignment-photo-btn" class="secondary small">📷 Add photo</button>
<input type="file" id="assignment-photo-input" accept="image/*" capture="environment" hidden>
<span id="assignment-photo-status" class="hint"></span>
</div>
<label class="checkbox-label evidence-note-label">
<select id="assignment-student-note">
<option value="">I felt good about this</option>
<option value="needs_help">🙋 I need help with this</option>
<option value="not_sure">🤔 I did it but I'm not sure</option>
</select>
</label>
</div>
</details>
</div>
<button id="assignment-done-btn" class="check-btn">Mark as done ✓</button>
<div id="assignment-status" class="status-banner" hidden></div>
<div id="assignment-comment" class="parent-comment" hidden></div>
</div>
</section>
+28
View File
@@ -416,3 +416,31 @@ td.overdue, .overdue { background: var(--bad-bg) !important; }
}
.modal-box h3 { margin-bottom: 1rem; }
.modal-box .secondary { margin-top: 1rem; }
/* ---------- evidence forms ---------- */
.evidence-form { margin: 0.75rem 0; border: 1.5px dashed var(--accent); border-radius: 10px; padding: 0.75rem; }
.evidence-form summary { cursor: pointer; color: var(--accent); font-weight: 600; }
.evidence-inner { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem; }
.evidence-inner textarea { width: 100%; border: 1.5px solid #d8d2c0; border-radius: 8px; padding: 0.5rem; font-family: inherit; font-size: 1rem; resize: vertical; }
.evidence-photo-row { display: flex; align-items: center; gap: 0.5rem; }
.evidence-note-label select { width: 100%; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.parent-comment { background: var(--good-bg); border-radius: 10px; padding: 0.75rem 1rem; margin: 0.75rem 0; color: var(--good); }
.status-banner.bad { background: var(--bad-bg); color: var(--bad); border-radius: 10px; padding: 0.75rem 1rem; margin: 0.75rem 0; }
/* ---------- offline 3-state tasks ---------- */
.today-row-offline { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 0.5rem; text-align: left; }
.offline-main { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.offline-main .row-title { flex: 1; font-weight: 600; }
.offline-controls { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
.offline-status-btn { padding: 0.3rem 0.75rem; border: 1.5px solid var(--muted); border-radius: 20px; background: transparent; font-size: 0.85rem; cursor: pointer; }
.offline-status-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; }
.offline-evidence-form { border: none; padding: 0; }
.offline-evidence-form summary { font-size: 0.85rem; color: var(--muted); cursor: pointer; }
/* ---------- grading queue cards ---------- */
.grading-card { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; }
.grading-header { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.6rem; }
.grading-header .grow { flex: 1; font-weight: 600; }
.grading-footer { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.6rem; }
.comment-input { flex: 1; min-width: 140px; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.badge-tag { background: var(--gold); color: #333; border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.8rem; font-weight: 600; }
+75 -32
View File
@@ -462,7 +462,7 @@ app.get('/api/admin/courses/:id', requirePin, (req, res) => {
// Full item detail for the admin item editor (quiz correct answers always included)
app.get('/api/admin/items/:id', requirePin, (req, res) => {
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);
const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id, due_date, allow_retakes, prereq_item_id, evidence_mode, retake_policy FROM items WHERE id = ?`).get(req.params.id);
if (!item) return res.status(404).json({ error: 'No such item' });
if (item.type === 'quiz') {
item.questions = db.prepare(`
@@ -543,14 +543,15 @@ function saveQuizQuestions(itemId, questions) {
app.post('/api/items', requirePin, (req, res) => {
const err = validateItemBody(req.body);
if (err) return res.status(400).json({ error: err });
const { unitId, type, title, body, points, refId, dueDate, allowRetakes, prereqItemId } = req.body;
const { unitId, type, title, body, points, refId, dueDate, allowRetakes, prereqItemId, evidenceMode, retakePolicy } = req.body;
const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM items WHERE unit_id = ?`).get(unitId).n;
const id = db.prepare(`
INSERT INTO items (unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, prereq_item_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO items (unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, prereq_item_id, evidence_mode, retake_policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(unitId, type, String(title).trim(), String(body || ''), Number(points) || 0,
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null).lastInsertRowid;
sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null,
evidenceMode || 'none', retakePolicy || 'latest').lastInsertRowid;
if (type === 'quiz') {
saveQuizQuestions(id, req.body.questions);
const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0);
@@ -565,14 +566,15 @@ app.put('/api/items/:id', requirePin, (req, res) => {
if (!db.prepare(`SELECT id FROM items WHERE id = ?`).get(req.params.id)) {
return res.status(404).json({ error: 'No such item' });
}
const { type, title, body, points, refId, dueDate, allowRetakes, prereqItemId } = req.body;
const { type, title, body, points, refId, dueDate, allowRetakes, prereqItemId, evidenceMode, retakePolicy } = req.body;
const finalPoints = type === 'quiz'
? req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0)
: (Number(points) || 0);
db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ?, due_date = ?, allow_retakes = ?, prereq_item_id = ? WHERE id = ?`)
db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ?, due_date = ?, allow_retakes = ?, prereq_item_id = ?, evidence_mode = ?, retake_policy = ? WHERE id = ?`)
.run(type, String(title).trim(), String(body || ''), finalPoints,
(type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null,
dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null, req.params.id);
dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null,
evidenceMode || 'none', retakePolicy || 'latest', req.params.id);
if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions);
res.json({ ok: true });
});
@@ -640,7 +642,7 @@ app.get('/api/courses/:id/detail', (req, res) => {
app.get('/api/items/:id', (req, res) => {
const studentId = req.query.studentId;
const item = db.prepare(`
SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, i.due_date, i.allow_retakes, i.prereq_item_id,
SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, i.due_date, i.allow_retakes, i.prereq_item_id, i.evidence_mode, i.retake_policy,
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
WHERE i.id = ?
@@ -655,7 +657,7 @@ app.get('/api/items/:id', (req, res) => {
}
item.submission = db.prepare(`
SELECT status, score, points_possible, answers, completed_at, graded_at
SELECT status, score, points_possible, answers, completed_at, graded_at, parent_comment, student_note
FROM submissions WHERE student_id = ? AND item_id = ?
`).get(studentId, req.params.id) || null;
@@ -681,25 +683,31 @@ app.get('/api/items/:id', (req, res) => {
// Mark a lesson viewed, an assignment done, or a practice/flashcards session finished.
// Never downgrades an already-graded submission unless allow_retakes is set.
app.post('/api/items/:id/complete', (req, res) => {
const { studentId, date } = req.body;
const { studentId, date, evidenceNotes, evidencePhoto, studentNote } = req.body;
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.allow_retakes) {
db.prepare(`
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at)
VALUES (?, ?, 'done', ?, datetime('now'))
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at, evidence_notes, evidence_photo, student_note)
VALUES (?, ?, 'done', ?, datetime('now'), ?, ?, ?)
ON CONFLICT (student_id, item_id) DO UPDATE SET
status = 'done', points_possible = excluded.points_possible,
completed_at = datetime('now'), score = NULL, graded_at = NULL
`).run(studentId, req.params.id, item.points || null);
completed_at = datetime('now'), score = NULL, graded_at = NULL,
evidence_notes = excluded.evidence_notes, evidence_photo = excluded.evidence_photo,
student_note = excluded.student_note
`).run(studentId, req.params.id, item.points || null, evidenceNotes || null, evidencePhoto || null, studentNote || 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')
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at, evidence_notes, evidence_photo, student_note)
VALUES (?, ?, 'done', ?, datetime('now'), ?, ?, ?)
ON CONFLICT (student_id, item_id) DO UPDATE SET
completed_at = datetime('now'),
evidence_notes = COALESCE(excluded.evidence_notes, evidence_notes),
evidence_photo = COALESCE(excluded.evidence_photo, evidence_photo),
student_note = COALESCE(excluded.student_note, student_note)
WHERE submissions.status != 'graded'
`).run(studentId, req.params.id, item.points || null);
`).run(studentId, req.params.id, item.points || null, evidenceNotes || null, evidencePhoto || null, studentNote || null);
}
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
@@ -742,15 +750,16 @@ app.post('/api/items/:id/quiz-submit', (req, res) => {
app.get('/api/grading-queue', requirePin, (req, res) => {
res.json(db.prepare(`
SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible,
sub.evidence_notes, sub.evidence_photo, sub.student_note,
s.id AS studentId, s.name AS studentName, s.emoji,
i.id AS itemId, i.title AS itemTitle,
i.id AS itemId, i.title AS itemTitle, i.type AS itemType,
c.name AS courseName, u.name AS unitName
FROM submissions sub
JOIN students s ON s.id = sub.student_id
JOIN items i ON i.id = sub.item_id
JOIN units u ON u.id = i.unit_id
JOIN courses c ON c.id = u.course_id
WHERE sub.status = 'done' AND i.type = 'assignment'
WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards')
ORDER BY sub.completed_at
`).all());
});
@@ -758,8 +767,12 @@ app.get('/api/grading-queue', requirePin, (req, res) => {
app.put('/api/submissions/:id/grade', requirePin, (req, res) => {
const score = Number(req.body.score);
if (Number.isNaN(score)) return res.status(400).json({ error: 'Score must be a number' });
db.prepare(`UPDATE submissions SET score = ?, status = 'graded', graded_at = datetime('now') WHERE id = ?`)
.run(score, req.params.id);
const comment = req.body.parentComment !== undefined ? String(req.body.parentComment).trim() || null : undefined;
db.prepare(`
UPDATE submissions SET score = ?, status = 'graded', graded_at = datetime('now')
${comment !== undefined ? ', parent_comment = ?' : ''}
WHERE id = ?
`).run(...(comment !== undefined ? [score, comment, req.params.id] : [score, req.params.id]));
res.json({ ok: true });
});
@@ -770,7 +783,7 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
if (!course) return res.status(404).json({ error: 'No such course' });
const gradableItems = db.prepare(`
SELECT i.id, i.title, i.type, i.points, i.due_date
SELECT i.id, i.title, i.type, i.points, i.due_date, i.retake_policy
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
@@ -781,7 +794,9 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
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 scoreStmt = db.prepare(`SELECT score, points_possible, status, parent_comment FROM submissions WHERE student_id = ? AND item_id = ?`);
const histBest = db.prepare(`SELECT MAX(score * 1.0 / points_possible) AS ratio, MAX(score) AS score, points_possible FROM submission_history WHERE student_id = ? AND item_id = ? AND points_possible > 0`);
const histAvg = db.prepare(`SELECT AVG(score) AS score, points_possible, COUNT(*) AS cnt FROM submission_history WHERE student_id = ? AND item_id = ? AND points_possible > 0`);
const now = today();
for (const s of students) {
s.scores = {};
@@ -789,9 +804,17 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
for (const item of gradableItems) {
const row = scoreStmt.get(s.id, item.id);
const overdue = !!(item.due_date && item.due_date < now && (!row || row.status !== 'graded'));
s.scores[item.id] = row ? { ...row, overdue } : (overdue ? { overdue: true } : null);
let effectiveScore = row?.score;
if (row?.status === 'graded' && item.retake_policy === 'highest') {
const best = histBest.get(s.id, item.id);
if (best?.score !== null) effectiveScore = best.score;
} else if (row?.status === 'graded' && item.retake_policy === 'average') {
const avg = histAvg.get(s.id, item.id);
if (avg?.cnt > 0) effectiveScore = Math.round(avg.score * 10) / 10;
}
s.scores[item.id] = row ? { ...row, score: effectiveScore, overdue } : (overdue ? { overdue: true } : null);
if (row && row.status === 'graded' && row.points_possible) {
earned += row.score;
earned += effectiveScore ?? 0;
possible += row.points_possible;
}
}
@@ -839,6 +862,20 @@ app.get('/api/gradebook/:courseId/csv', requirePin, (req, res) => {
res.send([header, ...rows].map((r) => r.join(',')).join('\n'));
});
// 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);
if (!row) return res.status(404).json({ error: 'Not found' });
res.json(row);
});
// Evidence detail for a schedule item (offline task)
app.get('/api/schedule/:id/evidence', requirePin, (req, res) => {
const row = db.prepare(`SELECT evidence_notes, evidence_photo FROM schedule WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'No such task' });
res.json(row);
});
// Score history for a student on one item
app.get('/api/items/:id/history', (req, res) => {
const { studentId } = req.query;
@@ -898,7 +935,8 @@ app.get('/api/schedule/:studentId', (req, res) => {
const date = isDateStr(req.query.date) ? req.query.date : null;
if (!date) return res.status(400).json({ error: 'date=YYYY-MM-DD required' });
const tasks = db.prepare(`
SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done,
SELECT sc.id, sc.date, sc.title AS offlineTitle, sc.done, sc.status AS offlineStatus,
sc.evidence_notes IS NOT NULL OR sc.evidence_photo IS NOT NULL AS hasEvidence,
i.id AS itemId, i.type, i.title AS itemTitle,
c.name AS courseName, c.color AS courseColor,
sub.status AS subStatus, sub.score, sub.points_possible
@@ -913,15 +951,20 @@ app.get('/api/schedule/:studentId', (req, res) => {
res.json({ tasks });
});
// Kid toggles a standalone offline task
// Kid updates a standalone offline task: status (not_started/in_progress/done) + optional evidence
app.post('/api/schedule/:id/done', (req, res) => {
const row = db.prepare(`SELECT item_id FROM schedule WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'No such task' });
if (row.item_id) return res.status(400).json({ error: 'Course items complete themselves — open the item instead' });
const done = req.body.done ? 1 : 0;
const status = ['not_started', 'in_progress', 'done'].includes(req.body.status) ? req.body.status : (req.body.done ? 'done' : 'not_started');
const done = status === 'done' ? 1 : 0;
db.prepare(`
UPDATE schedule SET done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE id = ?
`).run(done, done, req.params.id);
UPDATE schedule SET
status = ?, done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END,
evidence_notes = COALESCE(?, evidence_notes),
evidence_photo = COALESCE(?, evidence_photo)
WHERE id = ?
`).run(status, done, done, req.body.evidenceNotes || null, req.body.evidencePhoto || null, req.params.id);
res.json({ ok: true });
});