Add worksheet short-answer grading view, student inbox, and parent-student messaging

- Grading queue now shows short-answer questions and student responses inline so the parent can read answers before scoring
- Student inbox tab shows all graded items with score, percentage, and parent feedback; unread badge appears on login
- Parent-student messaging: students send messages from the Inbox tab; Messages panel in admin shows threads with unread count badge, full conversation history, and reply compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-20 13:28:28 -04:00
1 parent 2834738598
commit 556b570b60
7 files changed
+353 -2

No files matched your search

+9
View File
@@ -206,6 +206,15 @@ db.exec(`
answers TEXT,
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
body TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT (datetime('now')),
read_at TEXT
);
`);
function tryAlter(sql) {
+12
View File
@@ -28,6 +28,7 @@
<button class="nav-pill" data-panel="courses">📚 Courses</button>
<button class="nav-pill" data-panel="planner">🗓️ Planner</button>
<button class="nav-pill" data-panel="grading">✅ Grading</button>
<button class="nav-pill" data-panel="messages">💬 Messages</button>
<button class="nav-pill" data-panel="gradebook">📊 Gradebook</button>
<button class="nav-pill" data-panel="spelling">✏️ Spelling</button>
<button class="nav-pill" data-panel="decks">🗂️ Flashcards</button>
@@ -376,6 +377,17 @@
</div>
</section>
<!-- Messages -->
<section id="panel-messages" class="panel">
<h1>Messages</h1>
<div class="msg-admin-cols">
<div id="msg-thread-list" class="msg-thread-list"></div>
<div id="msg-admin-thread" class="msg-admin-thread">
<p class="hint" style="padding:1rem">Select a student to view the conversation.</p>
</div>
</div>
</section>
<!-- Gradebook -->
<section id="panel-gradebook" class="panel">
<h1>Gradebook</h1>
+115 -1
View File
@@ -78,6 +78,7 @@ async function unlock() {
$('#console').hidden = false;
showPanel('kids');
loadGradingBadge();
loadMessagesBadge();
}
async function loadGradingBadge() {
@@ -110,7 +111,7 @@ function showPanel(name) {
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name));
const loaders = {
kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
settings: loadSettings,
};
if (loaders[name]) loaders[name]();
@@ -1863,6 +1864,26 @@ async function loadGrading() {
const rows = filterEl.value ? allRows.filter((r) => String(r.studentId) === filterEl.value) : allRows;
function renderShortAnswerBlock(r) {
if (r.itemType !== 'worksheet' || !r.body || !r.answers) return '';
let wd, answers;
try { wd = JSON.parse(r.body); } catch { return ''; }
try { answers = JSON.parse(r.answers); } catch { answers = {}; }
const rows = [];
(wd.sections || []).forEach((section, si) => {
(section.questions || []).forEach((q, qi) => {
if (q.type !== 'short') return;
const response = (answers[`${si}:${qi}`] || '').trim();
rows.push(`<div class="sa-row">
<div class="sa-question">${esc(q.text)}</div>
<div class="sa-response">${response ? esc(response) : '<em class="hint">No answer written</em>'}</div>
</div>`);
});
});
if (!rows.length) return '';
return `<div class="sa-block"><strong style="font-size:.85rem;color:var(--muted)">Short answers</strong>${rows.join('')}</div>`;
}
$('#grading-empty').hidden = rows.length > 0;
$('#grading-rows').innerHTML = rows
.map((r) => {
@@ -1879,6 +1900,7 @@ async function loadGrading() {
<span class="hint">${esc(r.courseName)} · ${esc(r.unitName)}</span>
${noteTag}
</div>
${renderShortAnswerBlock(r)}
${evidenceBtn}
<div class="grading-footer">
<div class="grade-score-row">
@@ -1927,6 +1949,98 @@ async function loadGrading() {
$('#grading-student-filter').addEventListener('change', loadGrading);
// ============================================================
// Messages
// ============================================================
async function loadMessagesBadge() {
try {
const { count } = await api('/api/admin/messages/count');
const pill = document.querySelector('.nav-pill[data-panel="messages"]');
if (!pill) return;
let badge = pill.querySelector('.msg-nav-badge');
if (count > 0) {
if (!badge) { badge = document.createElement('span'); badge.className = 'msg-nav-badge'; pill.appendChild(badge); }
badge.textContent = count;
pill.classList.add('grading-pending');
} else {
badge?.remove();
pill.classList.remove('grading-pending');
}
} catch { /* non-critical */ }
}
let activeMessageStudentId = null;
async function loadMessages() {
const threads = await api('/api/admin/messages');
const listEl = $('#msg-thread-list');
if (!threads.length) {
listEl.innerHTML = '<p class="hint" style="padding:1rem">No messages yet.</p>';
return;
}
listEl.innerHTML = threads.map((t) => `
<button class="msg-student-btn${t.unread > 0 ? ' msg-has-unread' : ''}" data-student-id="${t.studentId}">
<span class="msg-student-avatar">${esc(t.emoji)}</span>
<span class="msg-student-info">
<strong>${esc(t.studentName)}</strong>
${t.unread > 0 ? `<span class="feedback-badge">${t.unread} new</span>` : ''}
<span class="msg-preview">${esc((t.lastBody || '').slice(0, 60))}</span>
</span>
</button>
`).join('');
listEl.querySelectorAll('[data-student-id]').forEach((btn) =>
btn.addEventListener('click', () => openAdminThread(Number(btn.dataset.studentId), threads.find((t) => t.studentId === Number(btn.dataset.studentId))))
);
if (activeMessageStudentId) openAdminThread(activeMessageStudentId, threads.find((t) => t.studentId === activeMessageStudentId));
}
async function openAdminThread(studentId, meta) {
activeMessageStudentId = studentId;
const msgs = await api(`/api/admin/messages/${studentId}`);
loadMessagesBadge();
const threadEl = $('#msg-admin-thread');
const name = meta ? `${esc(meta.emoji)} ${esc(meta.studentName)}` : 'Student';
threadEl.innerHTML = `
<div class="msg-admin-thread-header"><strong>${name}</strong></div>
<div class="msg-admin-msgs" id="msg-admin-msgs">${renderAdminMsgs(msgs)}</div>
<form class="msg-admin-compose" id="msg-admin-compose">
<input class="comment-input" id="msg-admin-input" placeholder="Reply to ${meta?.studentName || 'student'}…">
<button type="submit">Send</button>
</form>
`;
const msgsEl = $('#msg-admin-msgs');
msgsEl.scrollTop = msgsEl.scrollHeight;
$('#msg-admin-compose').addEventListener('submit', async (e) => {
e.preventDefault();
const input = $('#msg-admin-input');
const body = input.value.trim();
if (!body) return;
const lastMsgId = msgs[msgs.length - 1]?.id;
if (!lastMsgId) return;
input.value = '';
await api(`/api/messages/${lastMsgId}/reply`, { method: 'POST', body: { body } });
const updated = await api(`/api/admin/messages/${studentId}`);
msgsEl.innerHTML = renderAdminMsgs(updated);
msgsEl.scrollTop = msgsEl.scrollHeight;
loadMessagesBadge();
});
}
function renderAdminMsgs(msgs) {
if (!msgs.length) return '<p class="hint" style="padding:.75rem;text-align:center">No messages yet.</p>';
return msgs.map((m) => {
const isParent = m.sender === 'parent';
const time = new Date(m.sent_at + 'Z').toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return `<div class="msg-bubble-wrap ${isParent ? 'msg-mine' : 'msg-theirs'}">
<div class="msg-bubble">${esc(m.body)}</div>
<div class="msg-time">${time}</div>
</div>`;
}).join('');
}
// ============================================================
// Gradebook
// ============================================================
+87 -1
View File
@@ -304,7 +304,7 @@ document.querySelectorAll('.nav-pill').forEach((pill) =>
pill.addEventListener('click', () => switchTab(pill.dataset.tab))
);
const TAB_ORDER = ['today', 'courses', 'spelling'];
const TAB_ORDER = ['today', 'courses', 'spelling', 'inbox'];
let currentTab = 'today';
function switchTab(name) {
@@ -321,6 +321,91 @@ function switchTab(name) {
panel.classList.add(dir === 'right' ? 'sliding-right' : 'sliding-left');
}
});
if (name === 'inbox') loadInbox();
}
function inboxSeenKey(studentId) { return `inbox_seen_${studentId}`; }
function getSeenIds(studentId) {
try { return new Set(JSON.parse(localStorage.getItem(inboxSeenKey(studentId)) || '[]')); } catch { return new Set(); }
}
function markAllSeen(studentId, ids) {
localStorage.setItem(inboxSeenKey(studentId), JSON.stringify([...ids]));
}
async function loadInbox() {
const sid = currentStudent?.id;
if (!sid) return;
const [items, msgs] = await Promise.all([
api(`/api/students/${sid}/inbox`),
api(`/api/students/${sid}/messages`),
]);
const seen = getSeenIds(sid);
$('#inbox-empty').hidden = items.length > 0;
$('#inbox-list').innerHTML = items.map((r) => {
const pct = r.points_possible ? Math.round((r.score / r.points_possible) * 100) : null;
const isNew = !seen.has(r.submissionId);
const scoreClass = pct !== null ? (pct >= 80 ? 'score-good' : 'score-bad') : '';
const date = new Date(r.graded_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return `<div class="inbox-card${isNew ? ' inbox-new' : ''}">
<div class="inbox-card-header">
<span class="inbox-item-title">${esc(r.itemTitle)}</span>
${isNew ? '<span class="feedback-badge">New!</span>' : ''}
</div>
<div class="inbox-meta">${esc(r.courseName)} · ${esc(r.unitName)} · ${date}</div>
${r.points_possible !== null ? `<div class="inbox-score ${scoreClass}">🌟 ${r.score} / ${r.points_possible}${pct !== null ? ` (${pct}%)` : ''}</div>` : ''}
${r.parent_comment ? `<div class="parent-comment">💬 ${esc(r.parent_comment)}</div>` : ''}
</div>`;
}).join('');
markAllSeen(sid, items.map((r) => r.submissionId));
updateInboxBadge(sid, 0);
renderMsgThread(msgs);
}
function renderMsgThread(msgs) {
const thread = $('#msg-thread');
if (!thread) return;
if (!msgs.length) {
thread.innerHTML = '<p class="hint" style="text-align:center;padding:.75rem">No messages yet. Say hi to your parent!</p>';
return;
}
thread.innerHTML = msgs.map((m) => {
const isStudent = m.sender === 'student';
const time = new Date(m.sent_at + 'Z').toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return `<div class="msg-bubble-wrap ${isStudent ? 'msg-mine' : 'msg-theirs'}">
<div class="msg-bubble">${esc(m.body)}</div>
<div class="msg-time">${time}</div>
</div>`;
}).join('');
thread.scrollTop = thread.scrollHeight;
}
$('#msg-compose')?.addEventListener('submit', async (e) => {
e.preventDefault();
const input = $('#msg-input');
const body = input.value.trim();
if (!body || !currentStudent) return;
input.value = '';
await api(`/api/students/${currentStudent.id}/messages`, { method: 'POST', body: { body } });
const msgs = await api(`/api/students/${currentStudent.id}/messages`);
renderMsgThread(msgs);
});
function updateInboxBadge(studentId, count) {
const badge = $('#inbox-badge');
if (!badge) return;
badge.hidden = count === 0;
badge.textContent = count;
}
async function refreshInboxBadge(studentId) {
try {
const items = await api(`/api/students/${studentId}/inbox`);
const seen = getSeenIds(studentId);
const unseen = items.filter((r) => !seen.has(r.submissionId)).length;
updateInboxBadge(studentId, unseen);
} catch { /* non-critical */ }
}
async function openKid(id, tab = 'today', welcome = false) {
@@ -330,6 +415,7 @@ async function openKid(id, tab = 'today', welcome = false) {
api(`/api/courses/mine/${id}`),
api(`/api/students/${id}/tests`),
]);
refreshInboxBadge(id);
currentStudent = state.student;
youngLearnerMode = !!currentStudent.young_learner;
applyTheme(currentStudent.theme || 'blue');
+15
View File
@@ -78,6 +78,7 @@
<button class="nav-pill active" data-tab="today">📋 Today</button>
<button class="nav-pill" data-tab="courses">📚 My Courses</button>
<button class="nav-pill" data-tab="spelling">✏️ Spelling</button>
<button class="nav-pill" data-tab="inbox">📬 Inbox<span id="inbox-badge" class="feedback-badge" hidden></span></button>
</div>
<div id="tab-today" class="tab-panel">
@@ -99,6 +100,20 @@
</div>
<div id="kid-test-history"></div>
</div>
<div id="tab-inbox" class="tab-panel" hidden>
<div id="inbox-list"></div>
<p id="inbox-empty" class="hint" hidden>No grades yet — check back after your parent reviews your work!</p>
<div class="msg-thread-wrap">
<h2 class="msg-thread-title">💬 Messages</h2>
<div id="msg-thread" class="msg-thread"></div>
<form id="msg-compose" class="msg-compose">
<textarea id="msg-input" class="msg-input" placeholder="Write a message to your parent…" rows="2"></textarea>
<button type="submit" class="msg-send-btn">Send ➤</button>
</form>
</div>
</div>
</section>
<!-- All-done celebration overlay -->
+44
View File
@@ -987,6 +987,11 @@ td.overdue, .overdue { background: var(--bad-bg) !important; }
.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; }
.sa-block { background: var(--bg); border: 1px solid #e2dccf; border-radius: 10px; padding: 0.75rem; margin: 0.5rem 0; display: flex; flex-direction: column; gap: 0.6rem; }
.sa-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.sa-question { font-size: 0.9rem; color: var(--muted); }
.sa-response { font-size: 0.95rem; font-weight: 600; color: var(--ink); white-space: pre-wrap; }
@media (max-width: 600px) { .sa-row { grid-template-columns: 1fr; } }
/* ---------- themes ---------- */
body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; }
@@ -1246,6 +1251,45 @@ body.bg-bubbles {
border-left: 4px solid var(--good);
font-size: 1.05rem;
}
.inbox-card { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; }
.inbox-card.inbox-new { border-color: var(--accent); }
.inbox-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
.inbox-item-title { font-weight: 700; font-size: 1.05rem; flex: 1; }
.inbox-meta { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.4rem; }
.inbox-score { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.25rem; }
.inbox-score.score-good { color: var(--good); }
.inbox-score.score-bad { color: var(--bad); }
/* ---------- messaging ---------- */
.msg-thread-wrap { margin-top: 1.5rem; background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; overflow: hidden; }
.msg-thread-title { margin: 0; padding: .75rem 1rem; font-size: 1rem; border-bottom: 1px solid #e2dccf; }
.msg-thread { max-height: 320px; overflow-y: auto; padding: .75rem 1rem; display: flex; flex-direction: column; gap: .5rem; }
.msg-compose { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid #e2dccf; }
.msg-input { flex: 1; padding: .5rem .75rem; border: 1.5px solid #d8d2c0; border-radius: 10px; font-family: inherit; font-size: .95rem; resize: none; }
.msg-send-btn { padding: .5rem 1rem; border-radius: 10px; background: var(--accent); color: #fff; border: none; font-size: .9rem; cursor: pointer; white-space: nowrap; }
.msg-bubble-wrap { display: flex; flex-direction: column; max-width: 75%; }
.msg-mine { align-self: flex-end; align-items: flex-end; }
.msg-theirs { align-self: flex-start; align-items: flex-start; }
.msg-bubble { padding: .55rem .85rem; border-radius: 16px; font-size: .95rem; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
.msg-mine .msg-bubble { background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
.msg-theirs .msg-bubble { background: #e8e2d4; color: var(--ink); border-bottom-left-radius: 4px; }
.msg-time { font-size: .72rem; color: var(--muted); margin-top: .2rem; }
/* admin messages panel */
.msg-admin-cols { display: grid; grid-template-columns: 240px 1fr; gap: 1rem; height: calc(100vh - 180px); }
.msg-thread-list { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 12px; overflow-y: auto; display: flex; flex-direction: column; }
.msg-student-btn { background: none; border: none; border-bottom: 1px solid #e2dccf; padding: .75rem; text-align: left; cursor: pointer; display: flex; gap: .6rem; align-items: flex-start; }
.msg-student-btn:hover, .msg-student-btn.active { background: var(--accent-light, #e8f0fe); }
.msg-student-btn.msg-has-unread { font-weight: 700; }
.msg-student-avatar { font-size: 1.5rem; line-height: 1; }
.msg-student-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.msg-preview { font-size: .8rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; font-weight: 400; }
.msg-nav-badge { background: var(--bad); color: #fff; border-radius: 999px; font-size: .72rem; font-weight: 800; padding: .1rem .45rem; margin-left: .35rem; }
.msg-admin-thread { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.msg-admin-thread-header { padding: .75rem 1rem; border-bottom: 1px solid #e2dccf; font-size: 1rem; }
.msg-admin-msgs { flex: 1; overflow-y: auto; padding: .75rem 1rem; display: flex; flex-direction: column; gap: .5rem; }
.msg-admin-compose { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid #e2dccf; }
@media (max-width: 640px) { .msg-admin-cols { grid-template-columns: 1fr; height: auto; } }
/* ---------- quiz / matching instructions ---------- */
.quiz-instructions {
+71
View File
@@ -1727,6 +1727,7 @@ 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,
sub.answers, i.body,
s.id AS studentId, s.name AS studentName, s.emoji,
i.id AS itemId, i.title AS itemTitle, i.type AS itemType,
c.name AS courseName, u.name AS unitName
@@ -1754,6 +1755,76 @@ app.put('/api/submissions/:id/grade', requirePin, (req, res) => {
res.json({ ok: true });
});
// ---------- messages ----------
app.get('/api/students/:id/messages', (req, res) => {
const msgs = db.prepare(`SELECT id, sender, body, sent_at, read_at FROM messages WHERE student_id = ? ORDER BY sent_at`).all(req.params.id);
// Mark parent messages as read when student loads the thread
db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE student_id = ? AND sender = 'parent' AND read_at IS NULL`).run(req.params.id);
res.json(msgs);
});
app.post('/api/students/:id/messages', (req, res) => {
const body = String(req.body.body || '').trim();
if (!body) return res.status(400).json({ error: 'Message cannot be empty' });
const student = db.prepare(`SELECT id FROM students WHERE id = ?`).get(req.params.id);
if (!student) return res.status(404).json({ error: 'No such student' });
const { lastInsertRowid } = db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'student', ?)`).run(req.params.id, body);
res.json({ id: lastInsertRowid });
});
app.post('/api/messages/:id/reply', requirePin, (req, res) => {
const body = String(req.body.body || '').trim();
if (!body) return res.status(400).json({ error: 'Reply cannot be empty' });
const msg = db.prepare(`SELECT student_id FROM messages WHERE id = ?`).get(req.params.id);
if (!msg) return res.status(404).json({ error: 'No such message' });
const { lastInsertRowid } = db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'parent', ?)`).run(msg.student_id, body);
db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE id = ?`).run(req.params.id);
res.json({ id: lastInsertRowid });
});
app.get('/api/admin/messages', requirePin, (req, res) => {
const threads = db.prepare(`
SELECT s.id AS studentId, s.name AS studentName, s.emoji,
COUNT(CASE WHEN m.sender = 'student' AND m.read_at IS NULL THEN 1 END) AS unread,
MAX(m.sent_at) AS lastAt,
(SELECT body FROM messages WHERE student_id = s.id ORDER BY sent_at DESC LIMIT 1) AS lastBody
FROM students s
JOIN messages m ON m.student_id = s.id
GROUP BY s.id
ORDER BY lastAt DESC
`).all();
res.json(threads);
});
app.get('/api/admin/messages/count', requirePin, (req, res) => {
const { n } = db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE sender = 'student' AND read_at IS NULL`).get();
res.json({ count: n });
});
app.get('/api/admin/messages/:studentId', requirePin, (req, res) => {
const msgs = db.prepare(`SELECT id, sender, body, sent_at, read_at FROM messages WHERE student_id = ? ORDER BY sent_at`).all(req.params.studentId);
db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE student_id = ? AND sender = 'student' AND read_at IS NULL`).run(req.params.studentId);
res.json(msgs);
});
// ---------- student inbox ----------
app.get('/api/students/:id/inbox', (req, res) => {
const items = db.prepare(`
SELECT sub.id AS submissionId, sub.score, sub.points_possible, sub.graded_at, sub.parent_comment,
i.title AS itemTitle, c.name AS courseName, u.name AS unitName
FROM submissions sub
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.student_id = ? AND sub.status = 'graded' AND sub.graded_at IS NOT NULL
ORDER BY sub.graded_at DESC
LIMIT 50
`).all(req.params.id);
res.json(items);
});
// ---------- gradebook (parent) ----------
app.get('/api/gradebook/:courseId', requirePin, (req, res) => {