Add paper/print alternatives for off-screen learning
Build and Push Docker Image / build-and-push (push) Successful in 3m35s

- Spelling: "Blank test" print button per word list (numbered lines, no words shown) for dictated paper tests
- Spelling: "Record Paper Test" form in admin to log a paper test score to the gradebook
- Gradebook: "Record Paper Score" form to log a paper quiz/assignment score for any course item
- Flashcards: "Print" button per deck generates a cut-and-fold card sheet (fronts page + backs page)
- Crossword: print now includes a second page with a filled answer key
- Lessons + Assignments: 🖨 Print button in student view generates a clean printable page
- Word lists: new "CSV file" import tab — upload a .csv with word, sentence, definition columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-10-19 12:43:38 -04:00
1 parent 6baea15986
commit a92db291c7
5 files changed
+392 -3

No files matched your search

+60
View File
@@ -396,6 +396,28 @@
<button id="gradebook-csv-btn" class="secondary">⬇ Export CSV</button>
</div>
<div id="gradebook-table"></div>
<details class="admin-card" id="paper-score-details" style="margin:1.5rem 0">
<summary style="cursor:pointer;font-weight:600">📝 Record Paper Score</summary>
<p class="hint" style="margin:.5rem 0 .75rem">Use this to log a score from a paper quiz, assignment, or other item done off-screen.</p>
<div class="inline-form" style="flex-direction:column;gap:.6rem">
<div class="inline-form" style="gap:.5rem;flex-wrap:wrap">
<label>Student <select id="paper-score-student"></select></label>
<label>Course <select id="paper-score-course"></select></label>
</div>
<label>Item <select id="paper-score-item"><option value="">— select a course first —</option></select></label>
<div class="inline-form" style="gap:.5rem;align-items:center">
<label>Score <input type="number" id="paper-score-score" min="0" style="width:4.5rem" placeholder="0"></label>
<span style="padding-top:1.4rem">out of</span>
<label>Total <input type="number" id="paper-score-total" min="1" style="width:4.5rem"></label>
<label>Date <input type="date" id="paper-score-date"></label>
</div>
<div>
<button type="button" id="paper-score-save">Record score</button>
<span id="paper-score-status" class="hint" style="margin-left:.5rem"></span>
</div>
</div>
</details>
<h2>Spelling Test History</h2>
<div class="inline-form">
<label>Period <select id="spelling-tests-since">
@@ -432,6 +454,26 @@
<section id="panel-spelling" class="panel">
<h1>Spelling</h1>
<details class="admin-card" id="paper-test-details" style="margin-bottom:1.5rem">
<summary style="cursor:pointer;font-weight:600">📝 Record Paper Test</summary>
<div class="inline-form" style="flex-direction:column;gap:.6rem;margin-top:.75rem">
<div class="inline-form" style="gap:.5rem;flex-wrap:wrap">
<label>Student <select id="paper-test-student"></select></label>
<label>Word list <select id="paper-test-list"></select></label>
</div>
<div class="inline-form" style="gap:.5rem;align-items:center">
<label>Score <input type="number" id="paper-test-score" min="0" style="width:4.5rem" placeholder="0"></label>
<span style="padding-top:1.4rem">out of</span>
<label>Total <input type="number" id="paper-test-total" min="1" style="width:4.5rem"></label>
<label>Date <input type="date" id="paper-test-date"></label>
</div>
<div>
<button type="button" id="paper-test-save">Record test</button>
<span id="paper-test-status" class="hint" style="margin-left:.5rem"></span>
</div>
</div>
</details>
<h2>Practice Sessions</h2>
<div class="inline-form">
<label>Period <select id="sessions-since">
@@ -475,6 +517,7 @@
<div class="toggle-btns">
<button type="button" id="list-new-btn" class="secondary">+ New list</button>
<button type="button" id="list-bulk-btn" class="secondary">⬆ Bulk import</button>
<button type="button" id="list-csv-btn" class="secondary">📄 CSV file</button>
</div>
<div id="list-editor-section" hidden>
<h3 id="list-editor-title" class="section-form-title">New list</h3>
@@ -505,6 +548,23 @@
<p id="list-bulk-status" class="hint" style="margin:0.4rem 0 0"></p>
</div>
</div>
<div id="list-csv-section" hidden>
<h3 class="section-form-title">Import from CSV file</h3>
<div class="admin-card">
<p class="hint" style="margin:0 0 0.5rem">Upload a <code>.csv</code> or <code>.txt</code> file. Each row is one word. Columns: <code>word</code> (required), <code>sentence</code> (optional), <code>definition</code> (optional). A header row is ignored automatically.</p>
<div class="inline-form" style="flex-direction:column;gap:.5rem">
<input id="list-csv-name" placeholder="List name (required)" aria-label="List name for CSV import">
<input id="list-csv-group" placeholder="Group (optional)" aria-label="Group for CSV import" list="list-groups-datalist">
</div>
<input type="file" id="list-csv-file" accept=".csv,.txt" hidden>
<div class="inline-form" style="align-items:center;gap:.5rem;margin-top:.5rem">
<button type="button" id="list-csv-browse" class="secondary">📂 Choose file…</button>
<span id="list-csv-filename" class="hint" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button type="button" id="list-csv-go" class="secondary" disabled>📥 Import</button>
</div>
<p id="list-csv-status" class="hint" style="margin:0.4rem 0 0"></p>
</div>
</div>
</div>
</div>
</section>
+239 -1
View File
@@ -2070,10 +2070,11 @@ function renderAdminMsgs(msgs) {
let gradebookCourseId = null;
async function loadGradebookPanel() {
const courses = await api('/api/admin/courses');
const [courses, students] = await Promise.all([api('/api/admin/courses'), api('/api/students')]);
$('#gradebook-course').innerHTML = courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
if (courses.length) renderGradebook(courses[0].id);
loadSpellingTests();
initPaperScoreForm(students, courses);
}
async function loadSpellingTests() {
@@ -2275,6 +2276,7 @@ async function loadSpelling() {
renderLists(lists);
loadPracticeSessions();
loadPracticeActivity();
initPaperTestForm();
}
async function loadPracticeSessions() {
@@ -2477,6 +2479,7 @@ function renderLists(lists, filterText = '') {
<span class="muted-label">${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-assign-list="${l.id}">Assign →</button>
${l.builtin ? '' : `<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>`}
${l.builtin ? '' : `<button class="secondary small" data-blank-test="${l.id}">🖨 Blank test</button>`}
<button class="small" data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
${l.builtin
? `<button class="secondary small" data-hide-list="${l.id}">${isHidden ? 'Unhide' : 'Hide'}</button>`
@@ -2495,6 +2498,12 @@ function renderLists(lists, filterText = '') {
printSpellingWorksheet(list.name, list.words);
})
);
document.querySelectorAll('[data-blank-test]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.blankTest}`);
printBlankSpellingTest(list.name, list.words);
})
);
document.querySelectorAll('[data-edit-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.editList}`);
@@ -2595,14 +2604,22 @@ function clearListForm() {
function showSection(type, which) {
$(`#${type}-editor-section`).hidden = which !== 'editor';
$(`#${type}-bulk-section`).hidden = which !== 'bulk';
const csvSec = $(`#${type}-csv-section`);
if (csvSec) csvSec.hidden = which !== 'csv';
$(`#${type}-new-btn`).classList.toggle('active', which === 'editor');
$(`#${type}-bulk-btn`).classList.toggle('active', which === 'bulk');
const csvBtn = $(`#${type}-csv-btn`);
if (csvBtn) csvBtn.classList.toggle('active', which === 'csv');
}
function hideSections(type) {
$(`#${type}-editor-section`).hidden = true;
$(`#${type}-bulk-section`).hidden = true;
const csvSec = $(`#${type}-csv-section`);
if (csvSec) csvSec.hidden = true;
$(`#${type}-new-btn`).classList.remove('active');
$(`#${type}-bulk-btn`).classList.remove('active');
const csvBtn = $(`#${type}-csv-btn`);
if (csvBtn) csvBtn.classList.remove('active');
}
$('#list-new-btn').addEventListener('click', () => {
@@ -2613,6 +2630,55 @@ $('#list-bulk-btn').addEventListener('click', () => {
if ($('#list-bulk-section').hidden) showSection('list', 'bulk');
else hideSections('list');
});
$('#list-csv-btn').addEventListener('click', () => {
if ($('#list-csv-section').hidden) showSection('list', 'csv');
else hideSections('list');
});
$('#list-csv-browse').addEventListener('click', () => $('#list-csv-file').click());
$('#list-csv-file').addEventListener('change', () => {
const f = $('#list-csv-file').files[0];
$('#list-csv-filename').textContent = f ? f.name : '';
$('#list-csv-go').disabled = !f;
if (f && !$('#list-csv-name').value.trim()) {
$('#list-csv-name').value = f.name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
}
$('#list-csv-status').textContent = '';
});
$('#list-csv-go').addEventListener('click', async () => {
const f = $('#list-csv-file').files[0];
const name = $('#list-csv-name').value.trim();
const groupName = $('#list-csv-group').value.trim();
if (!f || !name) { $('#list-csv-status').textContent = 'Choose a file and enter a list name.'; return; }
$('#list-csv-status').textContent = 'Parsing…';
const text = await f.text();
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const words = [];
for (const line of lines) {
const parts = line.split(',').map((p) => p.trim().replace(/^["']|["']$/g, ''));
const word = parts[0];
if (!word || word.toLowerCase() === 'word') continue; // skip header
const entry = { word };
if (parts[1]) entry.sentence = parts[1];
if (parts[2]) entry.definition = parts[2];
words.push(entry);
}
if (words.length === 0) { $('#list-csv-status').textContent = 'No words found in file.'; return; }
try {
await api('/api/lists', { method: 'POST', body: { name, words, groupName } });
msg(`Imported "${name}" (${words.length} words).`);
$('#list-csv-file').value = '';
$('#list-csv-filename').textContent = '';
$('#list-csv-go').disabled = true;
$('#list-csv-name').value = '';
$('#list-csv-group').value = '';
$('#list-csv-status').textContent = '';
hideSections('list');
loadSpelling();
} catch (e) {
$('#list-csv-status').textContent = `Error: ${e.message}`;
}
});
function parseBulkSections(text) {
const sections = [];
@@ -2715,6 +2781,82 @@ async function printSpellingReport(testId) {
win.document.close();
}
function printBlankSpellingTest(name, words) {
const today = new Date().toLocaleDateString();
const rows = words
.map((w, i) => {
const hint = w.sentence ? `<div style="color:#888;font-size:.82em;font-style:italic;margin:.1rem 0 .3rem">${esc(w.sentence)}</div>` : '';
return `<tr>
<td style="width:2rem;color:#999;font-size:.9em;padding:.55rem .5rem .55rem 0">${i + 1}.</td>
<td>${hint}<div style="border-bottom:1.5px solid #aaa;min-width:16rem;height:1.5rem"></div></td>
</tr>`;
})
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Spelling Test: ${esc(name)}</title>
<style>
body { font-family: Georgia, serif; max-width: 560px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.3rem; border-bottom: 2px solid #222; padding-bottom: .4rem; }
.header { display:flex; gap: 2rem; margin: .75rem 0 1.25rem; }
.header label { flex:1; border-bottom: 1.5px solid #aaa; padding-bottom:.2rem; color:#555; font-size:.9rem; }
table { width: 100%; border-collapse: collapse; }
td { padding: .3rem .25rem; vertical-align: bottom; }
</style></head><body>
<h1>⭐ Spelling Test: ${esc(name)}</h1>
<div class="header">
<label>Name: ____________________________</label>
<label>Date: ${esc(today)}</label>
</div>
<table>${rows}</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
// Paper test recording form (populated when spelling panel loads)
function initPaperTestForm() {
const studentSel = $('#paper-test-student');
const listSel = $('#paper-test-list');
const scoreIn = $('#paper-test-score');
const totalIn = $('#paper-test-total');
const dateIn = $('#paper-test-date');
const status = $('#paper-test-status');
dateIn.value = new Date().toISOString().slice(0, 10);
studentSel.innerHTML = cachedStudents.map((s) =>
`<option value="${s.id}">${esc(s.emoji)} ${esc(s.name)}</option>`).join('');
listSel.innerHTML = cachedLists.map((l) =>
`<option value="${l.id}">${esc(l.name)}</option>`).join('');
// Auto-fill total when list changes
listSel.addEventListener('change', async () => {
const list = await api(`/api/lists/${listSel.value}`);
totalIn.value = list.words.length;
scoreIn.max = list.words.length;
});
if (listSel.value) listSel.dispatchEvent(new Event('change'));
$('#paper-test-save').onclick = async () => {
const score = Number(scoreIn.value), total = Number(totalIn.value);
if (!studentSel.value || !listSel.value || isNaN(score) || isNaN(total) || total <= 0) {
status.textContent = 'Fill in all fields.'; return;
}
status.textContent = 'Saving…';
try {
await api('/api/paper-spelling-test', { method: 'POST', body: {
studentId: Number(studentSel.value), listId: Number(listSel.value),
score, total, date: dateIn.value,
}});
status.textContent = `Saved: ${score}/${total}.`;
scoreIn.value = '';
$('#paper-test-details').removeAttribute('open');
loadSpellingTests();
} catch (e) {
status.textContent = `Error: ${e.message}`;
}
};
}
// ============================================================
// Flashcard decks
// ============================================================
@@ -2735,6 +2877,7 @@ function renderDecks(decks, filterText = '') {
const renderItem = (d) => `<div class="item-row">
<strong class="grow">${esc(d.name)}</strong>
<span>${d.cardCount} cards${d.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-print-deck="${d.id}">🖨 Print</button>
<button data-edit-deck="${d.id}">${d.builtin ? 'Copy & edit' : 'Edit'}</button>
<button class="danger" data-del-deck="${d.id}">Delete</button>
</div>`;
@@ -2753,6 +2896,12 @@ function renderDecks(decks, filterText = '') {
$('#deck-name').scrollIntoView({ behavior: 'smooth' });
})
);
document.querySelectorAll('[data-print-deck]').forEach((btn) =>
btn.addEventListener('click', async () => {
const deck = await api(`/api/decks/${btn.dataset.printDeck}`);
printFlashcardDeck(deck);
})
);
document.querySelectorAll('[data-del-deck]').forEach((btn) =>
btn.addEventListener('click', async () => {
if (!confirm('Delete this deck?')) return;
@@ -2833,6 +2982,95 @@ $('#deck-bulk-clear').addEventListener('click', () => {
$('#deck-bulk-status').textContent = '';
});
function printFlashcardDeck(deck) {
const cardStyle = `
display:inline-block; width:3.8in; min-height:1.5in; border:1.5px solid #aaa; border-radius:6px;
padding:.5rem .75rem; margin:.25rem; vertical-align:top; page-break-inside:avoid;
font-family:Georgia,serif; font-size:.95rem; box-sizing:border-box;`;
const cutLine = `border-top:1px dashed #bbb; margin:1rem 0; page-break-after:always;`;
const frontCards = deck.cards.map((c) =>
`<div style="${cardStyle}"><div style="font-size:.65rem;color:#aaa;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.35rem">Front</div>${esc(c.front)}</div>`
).join('');
const backCards = deck.cards.map((c) =>
`<div style="${cardStyle}"><div style="font-size:.65rem;color:#aaa;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.35rem">Back</div>${esc(c.back)}</div>`
).join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Flashcards: ${esc(deck.name)}</title>
<style>
body { font-family:Georgia,serif; padding:1rem; color:#222; }
h1 { font-size:1.2rem; margin-bottom:.5rem; }
p.hint { color:#888; font-size:.85rem; margin:.25rem 0 1rem; }
@media print { @page { size: letter; margin: .5in; } }
</style></head><body>
<h1>🗂️ ${esc(deck.name)}</h1>
<p class="hint">Print and cut — fold each card so the front faces out, back faces in.</p>
<h3 style="font-size:.9rem;color:#555;margin:.25rem 0">Fronts</h3>
${frontCards}
<div style="${cutLine}"></div>
<h3 style="font-size:.9rem;color:#555;margin:.25rem 0">Backs (same order)</h3>
${backCards}
<script>window.print()<\/script>
</body></html>`);
win.document.close();
}
// Paper score form (populated when gradebook panel loads)
function initPaperScoreForm(students, courses) {
const studentSel = $('#paper-score-student');
const courseSel = $('#paper-score-course');
const itemSel = $('#paper-score-item');
const scoreIn = $('#paper-score-score');
const totalIn = $('#paper-score-total');
const dateIn = $('#paper-score-date');
const status = $('#paper-score-status');
const GRADABLE = ['assignment', 'quiz', 'matching', 'crossword', 'spelling_test', 'worksheet'];
dateIn.value = new Date().toISOString().slice(0, 10);
studentSel.innerHTML = students.map((s) =>
`<option value="${s.id}">${esc(s.emoji || '')} ${esc(s.name)}</option>`).join('');
courseSel.innerHTML = `<option value="">— select —</option>` +
courses.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
courseSel.addEventListener('change', async () => {
if (!courseSel.value) { itemSel.innerHTML = '<option value="">— select a course first —</option>'; return; }
const course = await api(`/api/admin/courses/${courseSel.value}`);
const items = course.units.flatMap((u) => u.items.filter((it) => GRADABLE.includes(it.type)));
if (items.length === 0) {
itemSel.innerHTML = '<option value="">No gradable items in this course</option>';
return;
}
itemSel.innerHTML = items.map((it) => `<option value="${it.id}" data-pts="${it.points || ''}">${esc(it.title)} (${it.type})</option>`).join('');
itemSel.dispatchEvent(new Event('change'));
});
itemSel.addEventListener('change', () => {
const opt = itemSel.selectedOptions[0];
const pts = opt ? Number(opt.dataset.pts) : NaN;
if (!isNaN(pts) && pts > 0) { totalIn.value = pts; scoreIn.max = pts; }
});
$('#paper-score-save').onclick = async () => {
const score = Number(scoreIn.value), total = Number(totalIn.value);
if (!studentSel.value || !itemSel.value || isNaN(score) || isNaN(total) || total <= 0) {
status.textContent = 'Fill in all fields.'; return;
}
status.textContent = 'Saving…';
try {
await api(`/api/paper-score/${itemSel.value}`, { method: 'POST', body: {
studentId: Number(studentSel.value), score, total, date: dateIn.value,
}});
status.textContent = `Saved: ${score}/${total}.`;
scoreIn.value = '';
$('#paper-score-details').removeAttribute('open');
loadGradebookPanel();
} catch (e) {
status.textContent = `Error: ${e.message}`;
}
};
}
// ============================================================
// Settings
// ============================================================
+43 -2
View File
@@ -1222,6 +1222,7 @@ async function openLesson(itemId) {
$('#lesson-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#lesson-title').textContent = item.title;
$('#lesson-body').innerHTML = item.body;
$('#lesson-print-btn').onclick = () => printItemPage('📖 ' + item.title, item.course_name + ' · ' + item.unit_name, item.body);
show('lesson');
const btn = $('#lesson-done-btn');
@@ -1252,6 +1253,7 @@ async function openAssignment(itemId) {
$('#assignment-title').textContent = item.title;
$('#assignment-points').textContent = item.points ? `Worth ${item.points} points` : '';
$('#assignment-body').innerHTML = item.body || '';
$('#assignment-print-btn').onclick = () => printItemPage('📝 ' + item.title, item.course_name + ' · ' + item.unit_name, item.body || '');
const btn = $('#assignment-done-btn');
const status = $('#assignment-status');
@@ -2053,7 +2055,27 @@ async function openCrossword(itemId) {
show('crossword');
}
function printCrosswordWorksheet(title, cw) {
function printItemPage(title, kicker, bodyHtml) {
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>${esc(title)}</title>
<style>
body{font-family:Georgia,serif;max-width:680px;margin:2rem auto;color:#222}
.kicker{color:#888;font-size:.85rem;margin-bottom:.25rem}
h1{font-size:1.4rem;margin:0 0 1rem;border-bottom:2px solid #222;padding-bottom:.4rem}
.body{line-height:1.65}
.body h2,.body h3{margin:1rem 0 .4rem}
.body ul,.body ol{padding-left:1.5rem}
@media print{body{margin:.75in;max-width:none}}
</style></head><body>
<p class="kicker">${esc(kicker)}</p>
<h1>${esc(title)}</h1>
<div class="body">${bodyHtml}</div>
<script>window.print()<\/script>
</body></html>`);
win.document.close();
}
function buildCrosswordSvg(cw, filled) {
const CELL = 28;
const numMap = {};
for (const w of [...(cw.across||[]), ...(cw.down||[])]) if (w.num) numMap[`${w.row},${w.col}`] = w.num;
@@ -2067,21 +2089,40 @@ function printCrosswordWorksheet(title, cw) {
svg += `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}" fill="#fff" stroke="#999" stroke-width="0.5"/>`;
const key = `${r},${c}`;
if (numMap[key]) svg += `<text x="${x+2}" y="${y+9}" font-size="6" font-family="sans-serif">${numMap[key]}</text>`;
if (filled && cw.grid[r][c]) {
const cx = x + CELL / 2, cy = y + CELL / 2 + 5;
svg += `<text x="${cx}" y="${cy}" font-size="13" font-family="sans-serif" text-anchor="middle" fill="#1a1a1a">${esc(cw.grid[r][c])}</text>`;
}
}
}
}
svg += '</svg>';
return svg;
}
function printCrosswordWorksheet(title, cw) {
const svg = buildCrosswordSvg(cw, false);
const svgKey = buildCrosswordSvg(cw, true);
const acrossHtml = (cw.across||[]).map((w) => `<li><strong>${w.num}.</strong> ${esc(w.clue)}</li>`).join('');
const downHtml = (cw.down||[]).map((w) => `<li><strong>${w.num}.</strong> ${esc(w.clue)}</li>`).join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>${esc(title)}</title>
<style>body{font-family:sans-serif;padding:1.5rem}h1{margin-bottom:1rem}.cols{display:flex;gap:2rem}ol{list-style:none;padding:0}li{margin:.2rem 0;font-size:.9rem}@media print{body{padding:.5rem}}</style>
<style>
body{font-family:sans-serif;padding:1.5rem}h1{margin-bottom:1rem}
.cols{display:flex;gap:2rem}ol{list-style:none;padding:0}li{margin:.2rem 0;font-size:.9rem}
.page-break{page-break-before:always;margin-top:2rem}
@media print{body{padding:.5rem}}
</style>
</head><body>
<h1>${esc(title)}</h1>${svg}
<div class="cols">
<div><h3>Across</h3><ol>${acrossHtml}</ol></div>
<div><h3>Down</h3><ol>${downHtml}</ol></div>
</div>
<div class="page-break">
<h2 style="margin-bottom:.75rem">Answer Key — ${esc(title)}</h2>
${svgKey}
</div>
<script>window.print();<\/script>
</body></html>`);
win.document.close();
+2
View File
@@ -160,6 +160,7 @@
<div class="item-card">
<p class="item-kicker" id="lesson-kicker"></p>
<h1 id="lesson-title"></h1>
<button id="lesson-print-btn" class="secondary small" style="float:right;margin-bottom:.5rem">🖨 Print</button>
<div id="lesson-body" class="lesson-body"></div>
<button id="lesson-done-btn" class="check-btn">Done reading ✓</button>
<div id="lesson-next-actions" class="next-actions" hidden></div>
@@ -172,6 +173,7 @@
<div class="item-card">
<p class="item-kicker" id="assignment-kicker"></p>
<h1 id="assignment-title"></h1>
<button id="assignment-print-btn" class="secondary small" style="float:right;margin-bottom:.5rem">🖨 Print</button>
<p id="assignment-points" class="points-label"></p>
<div id="assignment-body" class="lesson-body"></div>
<div id="assignment-evidence-section" hidden>
+48
View File
@@ -557,6 +557,54 @@ app.patch('/api/tests/:testId/correct', requirePin, (req, res) => {
res.json({ score: newScore, total: test.total });
});
// Record a paper spelling test (no per-word answers — score entered by parent)
app.post('/api/paper-spelling-test', requirePin, (req, res) => {
const { studentId, listId, score, total, itemId, date } = req.body;
const s = Number(score), t = Number(total);
if (!studentId || !listId || isNaN(s) || isNaN(t) || t <= 0 || s < 0 || s > t) {
return res.status(400).json({ error: 'studentId, listId, score (0–total) and total required' });
}
const testId = db.prepare(`INSERT INTO tests (student_id, list_id, score, total, item_id) VALUES (?, ?, ?, ?, ?)`)
.run(studentId, listId, s, t, itemId || null).lastInsertRowid;
if (itemId) {
db.prepare(`
INSERT INTO submissions (student_id, item_id, status, score, points_possible, completed_at, graded_at)
VALUES (?, ?, 'graded', ?, ?, datetime('now'), datetime('now'))
ON CONFLICT (student_id, item_id) DO UPDATE SET
status = 'graded', score = excluded.score, points_possible = excluded.points_possible,
completed_at = datetime('now'), graded_at = datetime('now')
`).run(studentId, itemId, s, t);
db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible) VALUES (?, ?, ?, ?)`)
.run(studentId, itemId, s, t);
markScheduleDone.run(studentId, itemId, isDateStr(date) ? date : today());
}
res.json({ testId, score: s, total: t });
});
// Record a paper score for any gradable course item (quiz, assignment, etc.)
app.post('/api/paper-score/:itemId', requirePin, (req, res) => {
const { studentId, score, total, date } = req.body;
const s = Number(score), t = Number(total);
if (!studentId || isNaN(s) || isNaN(t) || t <= 0 || s < 0 || s > t) {
return res.status(400).json({ error: 'studentId, score (0–total) and total required' });
}
const item = db.prepare(`SELECT id FROM items WHERE id = ?`).get(req.params.itemId);
if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare(`
INSERT INTO submissions (student_id, item_id, status, score, points_possible, completed_at, graded_at)
VALUES (?, ?, 'graded', ?, ?, datetime('now'), datetime('now'))
ON CONFLICT (student_id, item_id) DO UPDATE SET
status = 'graded', score = excluded.score, points_possible = excluded.points_possible,
completed_at = datetime('now'), graded_at = datetime('now')
`).run(studentId, item.id, s, t);
db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible) VALUES (?, ?, ?, ?)`)
.run(studentId, item.id, s, t);
markScheduleDone.run(studentId, item.id, isDateStr(date) ? date : today());
res.json({ score: s, total: t });
});
// ---------- spelling practice activity (parent view) ----------
app.get('/api/spelling-activity', requirePin, (req, res) => {