diff --git a/db.js b/db.js
index 770bf6f..5e6a3fd 100644
--- a/db.js
+++ b/db.js
@@ -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');
diff --git a/public/admin.html b/public/admin.html
index e94b455..a8ed4f0 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -120,6 +120,22 @@
+
@@ -189,6 +205,14 @@
Grading Queue
Nothing waiting to be graded π
+
+
diff --git a/public/admin.js b/public/admin.js
index bcb63b1..0d35917 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -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 += `

`;
+ if (notes) html += `
${esc(notes)}
`;
+ $('#evidence-lightbox-body').innerHTML = html || '
No evidence content.
';
+ $('#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) => `
- ${esc(r.emoji)}
- ${esc(r.studentName)}
- ${esc(r.itemTitle)}
- ${esc(r.courseName)} Β· ${esc(r.unitName)}
-
-
-
`
- )
+ .map((r) => {
+ const hasEvidence = r.evidence_notes || r.evidence_photo;
+ const noteTag = r.student_note ? `
${STUDENT_NOTE_LABEL[r.student_note] || r.student_note}` : '';
+ const evidenceBtn = hasEvidence
+ ? `
`
+ : '';
+ return `
+
+ ${evidenceBtn}
+
+
`;
+ })
.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();
- })
- );
+ });
+ });
}
// ============================================================
diff --git a/public/app.js b/public/app.js
index a903394..247facf 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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 `
`;
+ const st = t.offlineStatus || (t.done ? 'done' : 'not_started');
+ return `
+
+ ${OFFLINE_STATUS_ICON[st] || 'β¬'}
+ ${esc(t.offlineTitle)}
+ π${t.hasEvidence ? ' π' : ''}
+
+
+
+
+
+
+
+ π Add notes / photo
+
+
+
+
+
+
+
+
+
+
+
`;
}
const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done);
return `
diff --git a/public/styles.css b/public/styles.css
index e98716c..a8d5e9f 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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; }
diff --git a/server.js b/server.js
index e29ccb1..964e5b4 100644
--- a/server.js
+++ b/server.js
@@ -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 });
});