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 due_date TEXT`);
tryAlter(`ALTER TABLE items ADD COLUMN allow_retakes INTEGER NOT NULL DEFAULT 0`); 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 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'); 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) <label>Prerequisite item (must be done first)
<select id="ie-prereq"><option value="">None</option></select> <select id="ie-prereq"><option value="">None</option></select>
</label> </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 --> <!-- 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>
@@ -189,6 +205,14 @@
<h1>Grading Queue</h1> <h1>Grading Queue</h1>
<div id="grading-rows"></div> <div id="grading-rows"></div>
<p id="grading-empty" class="hint" hidden>Nothing waiting to be graded 🎉</p> <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> </section>
<!-- Gradebook --> <!-- Gradebook -->
+61 -14
View File
@@ -305,6 +305,8 @@ async function openItemEditor(unitId, itemId) {
$('#ie-due-date').value = item.due_date || ''; $('#ie-due-date').value = item.due_date || '';
$('#ie-allow-retakes').checked = !!item.allow_retakes; $('#ie-allow-retakes').checked = !!item.allow_retakes;
$('#ie-prereq').value = item.prereq_item_id || ''; $('#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 === '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);
@@ -324,6 +326,8 @@ async function openItemEditor(unitId, itemId) {
$('#ie-due-date').value = ''; $('#ie-due-date').value = '';
$('#ie-allow-retakes').checked = false; $('#ie-allow-retakes').checked = false;
$('#ie-prereq').value = ''; $('#ie-prereq').value = '';
$('#ie-evidence-mode').value = 'none';
$('#ie-retake-policy').value = 'latest';
$('#ie-delete').hidden = true; $('#ie-delete').hidden = true;
} }
updateItemFieldVisibility(); updateItemFieldVisibility();
@@ -488,6 +492,8 @@ $('#item-form').addEventListener('submit', async (e) => {
dueDate: $('#ie-due-date').value || null, dueDate: $('#ie-due-date').value || null,
allowRetakes: $('#ie-allow-retakes').checked, allowRetakes: $('#ie-allow-retakes').checked,
prereqItemId: prereqVal ? Number(prereqVal) : null, prereqItemId: prereqVal ? Number(prereqVal) : null,
evidenceMode: $('#ie-evidence-mode').value,
retakePolicy: $('#ie-retake-policy').value,
}; };
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) => ({
@@ -683,30 +689,71 @@ async function printWeekReport(studentId, start) {
// Grading queue // 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() { async function loadGrading() {
const rows = await api('/api/grading-queue'); const rows = await api('/api/grading-queue');
$('#grading-empty').hidden = rows.length > 0; $('#grading-empty').hidden = rows.length > 0;
$('#grading-rows').innerHTML = rows $('#grading-rows').innerHTML = rows
.map( .map((r) => {
(r) => `<div class="item-row"> const hasEvidence = r.evidence_notes || r.evidence_photo;
<span>${esc(r.emoji)}</span> const noteTag = r.student_note ? `<span class="badge-tag">${STUDENT_NOTE_LABEL[r.student_note] || r.student_note}</span>` : '';
<strong class="grow">${esc(r.studentName)}</strong> const evidenceBtn = hasEvidence
<span>${esc(r.itemTitle)}</span> ? `<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>`
<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}"> return `<div class="grading-card">
<button data-save-grade="${r.submissionId}">Save</button> <div class="grading-header">
</div>` <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(''); .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 () => { btn.addEventListener('click', async () => {
const input = document.querySelector(`.grade-input[data-sub="${btn.dataset.saveGrade}"]`); 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; 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(); 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) ---------- // ---------- text to speech (spelling module) ----------
function speak(text, rate = 0.85) { function speak(text, rate = 0.85) {
@@ -145,16 +185,38 @@ async function openKid(id, tab = 'today') {
switchTab(tab); switchTab(tab);
} }
const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' };
function renderToday(tasks) { function renderToday(tasks) {
$('#today-empty').hidden = tasks.length > 0; $('#today-empty').hidden = tasks.length > 0;
$('#today-list').innerHTML = tasks $('#today-list').innerHTML = tasks
.map((t) => { .map((t) => {
if (!t.itemId) { if (!t.itemId) {
return `<button class="today-row offline" data-schedule-id="${t.id}"> const st = t.offlineStatus || (t.done ? 'done' : 'not_started');
<span class="row-check">${t.done ? '✅' : '⬜'}</span> return `<div class="today-row-offline" data-schedule-id="${t.id}">
<span class="row-title">${esc(t.offlineTitle)}</span> <div class="offline-main">
<span class="row-badge">📌 Offline</span> <span class="row-check">${OFFLINE_STATUS_ICON[st] || '⬜'}</span>
</button>`; <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); 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}"> return `<button class="today-row" data-item-id="${t.itemId}" data-item-type="${t.type}">
@@ -165,13 +227,43 @@ function renderToday(tasks) {
}) })
.join(''); .join('');
document.querySelectorAll('.today-row.offline').forEach((row) => // Offline status buttons
row.addEventListener('click', async () => { document.querySelectorAll('.offline-status-btn').forEach((btn) =>
const done = row.querySelector('.row-check').textContent.trim() !== '✅'; btn.addEventListener('click', async () => {
await api(`/api/schedule/${row.dataset.scheduleId}/done`, { method: 'POST', body: { done } }); await api(`/api/schedule/${btn.dataset.sid}/done`, { method: 'POST', body: { status: btn.dataset.status } });
openKid(currentStudent.id, 'today'); 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) => document.querySelectorAll('.today-row:not(.offline)').forEach((row) =>
row.addEventListener('click', () => { row.addEventListener('click', () => {
backTarget = () => openKid(currentStudent.id, 'today'); backTarget = () => openKid(currentStudent.id, 'today');
@@ -293,6 +385,12 @@ async function openLesson(itemId) {
// ---------- assignment ---------- // ---------- assignment ----------
async function openAssignment(itemId) { 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}`); const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
$('#assignment-kicker').textContent = `${item.course_name} · ${item.unit_name}`; $('#assignment-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#assignment-title').textContent = item.title; $('#assignment-title').textContent = item.title;
@@ -301,15 +399,30 @@ async function openAssignment(itemId) {
const btn = $('#assignment-done-btn'); const btn = $('#assignment-done-btn');
const status = $('#assignment-status'); 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') { if (item.submission && item.submission.status === 'graded') {
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}`;
evidenceSection.hidden = !needsEvidence || !item.allow_retakes;
if (item.allow_retakes) { if (item.allow_retakes) {
btn.hidden = false; btn.hidden = false;
btn.textContent = '🔁 Retake'; btn.textContent = '🔁 Retake';
btn.onclick = async () => { 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); openAssignment(itemId);
}; };
} else { } else {
@@ -317,15 +430,23 @@ async function openAssignment(itemId) {
} }
} else if (item.submission && item.submission.status === 'done') { } else if (item.submission && item.submission.status === 'done') {
btn.hidden = true; btn.hidden = true;
evidenceSection.hidden = true;
status.hidden = false; status.hidden = false;
status.className = 'status-banner'; status.className = 'status-banner';
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 ✓'; btn.textContent = item.evidence_mode === 'required' ? 'Submit with evidence ✓' : 'Mark as done ✓';
status.hidden = true; status.hidden = true;
evidenceSection.hidden = !needsEvidence;
btn.onclick = async () => { 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); openAssignment(itemId);
}; };
} }
+21
View File
@@ -73,8 +73,29 @@
<h1 id="assignment-title"></h1> <h1 id="assignment-title"></h1>
<p id="assignment-points" class="points-label"></p> <p id="assignment-points" class="points-label"></p>
<div id="assignment-body" class="lesson-body"></div> <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> <button id="assignment-done-btn" class="check-btn">Mark as done ✓</button>
<div id="assignment-status" class="status-banner" hidden></div> <div id="assignment-status" class="status-banner" hidden></div>
<div id="assignment-comment" class="parent-comment" hidden></div>
</div> </div>
</section> </section>
+28
View File
@@ -416,3 +416,31 @@ td.overdue, .overdue { background: var(--bad-bg) !important; }
} }
.modal-box h3 { margin-bottom: 1rem; } .modal-box h3 { margin-bottom: 1rem; }
.modal-box .secondary { margin-top: 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) // 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, 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) 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(`
@@ -543,14 +543,15 @@ 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, 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 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, due_date, allow_retakes, prereq_item_id) INSERT INTO items (unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, prereq_item_id, evidence_mode, retake_policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null).lastInsertRowid; sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null,
evidenceMode || 'none', retakePolicy || 'latest').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);
@@ -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)) { 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, dueDate, allowRetakes, prereqItemId } = req.body; const { type, title, body, points, refId, dueDate, allowRetakes, prereqItemId, evidenceMode, retakePolicy } = 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 = ?, 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, .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,
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); if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -640,7 +642,7 @@ 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, 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 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 = ?
@@ -655,7 +657,7 @@ app.get('/api/items/:id', (req, res) => {
} }
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, parent_comment, student_note
FROM submissions WHERE student_id = ? AND item_id = ? FROM submissions WHERE student_id = ? AND item_id = ?
`).get(studentId, req.params.id) || null; `).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. // Mark a lesson viewed, an assignment done, or a practice/flashcards session finished.
// Never downgrades an already-graded submission unless allow_retakes is set. // 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, evidenceNotes, evidencePhoto, studentNote } = req.body;
const item = db.prepare(`SELECT id, points, allow_retakes 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' });
if (item.allow_retakes) { if (item.allow_retakes) {
db.prepare(` db.prepare(`
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at) INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at, evidence_notes, evidence_photo, student_note)
VALUES (?, ?, 'done', ?, datetime('now')) VALUES (?, ?, 'done', ?, datetime('now'), ?, ?, ?)
ON CONFLICT (student_id, item_id) DO UPDATE SET ON CONFLICT (student_id, item_id) DO UPDATE SET
status = 'done', points_possible = excluded.points_possible, status = 'done', points_possible = excluded.points_possible,
completed_at = datetime('now'), score = NULL, graded_at = NULL completed_at = datetime('now'), score = NULL, graded_at = NULL,
`).run(studentId, req.params.id, item.points || 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 { } else {
db.prepare(` db.prepare(`
INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at) INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at, evidence_notes, evidence_photo, student_note)
VALUES (?, ?, 'done', ?, datetime('now')) VALUES (?, ?, 'done', ?, datetime('now'), ?, ?, ?)
ON CONFLICT (student_id, item_id) DO UPDATE SET completed_at = 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' 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()); 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) => { app.get('/api/grading-queue', requirePin, (req, res) => {
res.json(db.prepare(` res.json(db.prepare(`
SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible, 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, 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 c.name AS courseName, u.name AS unitName
FROM submissions sub FROM submissions sub
JOIN students s ON s.id = sub.student_id JOIN students s ON s.id = sub.student_id
JOIN items i ON i.id = sub.item_id JOIN items i ON i.id = sub.item_id
JOIN units u ON u.id = i.unit_id JOIN units u ON u.id = i.unit_id
JOIN courses c ON c.id = u.course_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 ORDER BY sub.completed_at
`).all()); `).all());
}); });
@@ -758,8 +767,12 @@ app.get('/api/grading-queue', requirePin, (req, res) => {
app.put('/api/submissions/:id/grade', requirePin, (req, res) => { app.put('/api/submissions/:id/grade', requirePin, (req, res) => {
const score = Number(req.body.score); const score = Number(req.body.score);
if (Number.isNaN(score)) return res.status(400).json({ error: 'Score must be a number' }); 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 = ?`) const comment = req.body.parentComment !== undefined ? String(req.body.parentComment).trim() || null : undefined;
.run(score, req.params.id); 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 }); 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' }); 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, 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 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
@@ -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 JOIN enrollments e ON e.student_id = s.id WHERE e.course_id = ? ORDER BY s.name
`).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, 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(); const now = today();
for (const s of students) { for (const s of students) {
s.scores = {}; s.scores = {};
@@ -789,9 +804,17 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
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);
const overdue = !!(item.due_date && item.due_date < now && (!row || row.status !== 'graded')); 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) { if (row && row.status === 'graded' && row.points_possible) {
earned += row.score; earned += effectiveScore ?? 0;
possible += row.points_possible; 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')); 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 // Score history for a student on one item
app.get('/api/items/:id/history', (req, res) => { app.get('/api/items/:id/history', (req, res) => {
const { studentId } = req.query; 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; const date = isDateStr(req.query.date) ? req.query.date : null;
if (!date) return res.status(400).json({ error: 'date=YYYY-MM-DD required' }); if (!date) return res.status(400).json({ error: 'date=YYYY-MM-DD required' });
const tasks = db.prepare(` 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, i.id AS itemId, i.type, i.title AS itemTitle,
c.name AS courseName, c.color AS courseColor, c.name AS courseName, c.color AS courseColor,
sub.status AS subStatus, sub.score, sub.points_possible sub.status AS subStatus, sub.score, sub.points_possible
@@ -913,15 +951,20 @@ app.get('/api/schedule/:studentId', (req, res) => {
res.json({ tasks }); 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) => { app.post('/api/schedule/:id/done', (req, res) => {
const row = db.prepare(`SELECT item_id FROM schedule WHERE id = ?`).get(req.params.id); 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) 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' }); 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(` db.prepare(`
UPDATE schedule SET done = ?, done_at = CASE WHEN ? THEN datetime('now') ELSE NULL END WHERE id = ? UPDATE schedule SET
`).run(done, done, req.params.id); 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 }); res.json({ ok: true });
}); });