Add Worksheet item type with paste-and-parse authoring, digital submission, and print-blank

- New `worksheet` item type: admin pastes raw worksheet text, parser auto-detects fill-in-the-blank (___), true/false (T   F   ...), section headers (Part N —), and short-answer questions
- Admin editor shows each parsed question with inputs to set correct answers for FITB and T/F; short answers are marked parent-grades
- Kid view renders FITB as inline underline inputs, T/F as tap-to-toggle T/F buttons, and short answer as a textarea; Name/Date header auto-populated
- Auto-grades T/F (exact) and FITB (case-insensitive); worksheets with short answers go to grading queue as status=done
- Print blank button opens a formatted print window with underlines, circled T/F, and ruled lines for short answers
- "I handed in my paper copy" button lets kids mark a printed worksheet as turned in without digital submission, queuing it for parent grading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-03 15:59:13 -04:00
1 parent 9abb107096
commit 6f73acd03e
6 files changed
+720 -8

No files matched your search

+10
View File
@@ -174,6 +174,7 @@
<option value="matching">🔤 Matching</option> <option value="matching">🔤 Matching</option>
<option value="crossword">⬛ Crossword</option> <option value="crossword">⬛ Crossword</option>
<option value="flashcards">🗂️ Flashcards</option> <option value="flashcards">🗂️ Flashcards</option>
<option value="worksheet">📋 Worksheet</option>
</select> </select>
</label> </label>
<label>Title <input id="ie-title" placeholder="Item title"></label> <label>Title <input id="ie-title" placeholder="Item title"></label>
@@ -318,6 +319,15 @@
<label>Deck <select id="ie-deck"></select></label> <label>Deck <select id="ie-deck"></select></label>
</div> </div>
<div id="ie-field-worksheet" class="ie-field" hidden>
<label style="margin-bottom:.25rem">Paste your worksheet text
<textarea id="ie-ws-paste" rows="10" placeholder="Paste your printed worksheet here. The parser handles:&#10;• Name: ____ Date: ____ → header line&#10;• Part 1 — Title → section header&#10;• 1. Sentence with _____ blanks. → fill-in-the-blank&#10;• T F Statement text → true/false question&#10;• 4. Question with no blanks → short answer (parent grades)" style="width:100%;margin-top:.25rem;resize:vertical;font-family:monospace;font-size:.85rem"></textarea>
</label>
<button type="button" id="ie-ws-parse" class="secondary" style="margin:.5rem 0">⚡ Parse worksheet</button>
<p id="ie-ws-parse-status" class="hint" hidden></p>
<div id="ie-ws-editor"></div>
</div>
<div class="row-actions"> <div class="row-actions">
<button type="submit">Save item</button> <button type="submit">Save item</button>
<button type="button" id="ie-delete" class="danger" hidden>Delete item</button> <button type="button" id="ie-delete" class="danger" hidden>Delete item</button>
+252 -2
View File
@@ -37,11 +37,12 @@ function msg(text, type = 'success') {
const TYPE_ICON = { const TYPE_ICON = {
lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤', crossword: '⬛', lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤', crossword: '⬛',
spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️', spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️', worksheet: '📋',
}; };
const TYPE_LABEL = { const TYPE_LABEL = {
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword', lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword',
spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards', spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards',
worksheet: 'Worksheet',
}; };
// ---------- PIN gate ---------- // ---------- PIN gate ----------
@@ -546,6 +547,10 @@ async function openItemEditor(unitId, itemId) {
renderCrosswordPreview(item.crosswordData); renderCrosswordPreview(item.crosswordData);
$('#ie-crossword-status').textContent = `${item.crosswordData.placed || ''} words placed`; $('#ie-crossword-status').textContent = `${item.crosswordData.placed || ''} words placed`;
} }
if (item.type === 'worksheet' && item.worksheetData) {
currentWorksheetData = item.worksheetData;
renderWorksheetEditor(item.worksheetData);
}
$('#ie-delete').hidden = false; $('#ie-delete').hidden = false;
$('#ie-delete').onclick = async () => { $('#ie-delete').onclick = async () => {
if (!confirm('Delete this item and any grades for it?')) return; if (!confirm('Delete this item and any grades for it?')) return;
@@ -562,6 +567,10 @@ async function openItemEditor(unitId, itemId) {
$('#ie-quiz-instructions').value = ''; $('#ie-quiz-instructions').value = '';
$('#ie-matching-instructions').value = ''; $('#ie-matching-instructions').value = '';
currentCrosswordData = null; currentCrosswordData = null;
currentWorksheetData = null;
$('#ie-ws-paste').value = '';
$('#ie-ws-editor').innerHTML = '';
$('#ie-ws-parse-status').hidden = true;
$('#ie-points').value = 10; $('#ie-points').value = 10;
$('#ie-due-date').value = ''; $('#ie-due-date').value = '';
$('#ie-allow-retakes').checked = false; $('#ie-allow-retakes').checked = false;
@@ -585,8 +594,10 @@ function updateItemFieldVisibility() {
$('#ie-field-crossword').hidden = type !== 'crossword'; $('#ie-field-crossword').hidden = type !== 'crossword';
$('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test'; $('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test';
$('#ie-field-flashcards').hidden = type !== 'flashcards'; $('#ie-field-flashcards').hidden = type !== 'flashcards';
$('#ie-field-worksheet').hidden = type !== 'worksheet';
$('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz'; $('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz';
$('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' || type === 'flashcards'; $('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test'
|| type === 'flashcards' || type === 'worksheet';
} }
$('#ie-type').addEventListener('change', updateItemFieldVisibility); $('#ie-type').addEventListener('change', updateItemFieldVisibility);
@@ -962,6 +973,15 @@ $('#item-form').addEventListener('submit', async (e) => {
} }
body.crosswordData = currentCrosswordData; body.crosswordData = currentCrosswordData;
} }
if (type === 'worksheet') {
const wd = collectWorksheetAnswers();
if (!wd) {
$('#ie-error').textContent = 'Parse the worksheet first.';
$('#ie-error').hidden = false;
return;
}
body.worksheetData = wd;
}
const id = $('#ie-id').value; const id = $('#ie-id').value;
try { try {
@@ -975,6 +995,236 @@ $('#item-form').addEventListener('submit', async (e) => {
} }
}); });
// ============================================================
// Worksheet editor
// ============================================================
let currentWorksheetData = null;
function parseWorksheet(text) {
const lines = text.split('\n');
const sections = [];
let currentSection = null;
let hasHeader = false;
for (const rawLine of lines) {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed) continue;
// Name/Date header line
if (!hasHeader && /name\s*:/i.test(trimmed)) {
hasHeader = true;
continue;
}
// Section header: "Part 1 —", "Part 1:", "Section 1 —", etc.
if (/^(part|section)\s+\d+/i.test(trimmed)) {
currentSection = { title: trimmed, questions: [] };
sections.push(currentSection);
continue;
}
if (!currentSection) {
currentSection = { title: '', questions: [] };
sections.push(currentSection);
}
// True/False line: starts with T then whitespace then F
if (/^T\s+F\b/i.test(trimmed)) {
const text = trimmed.replace(/^T\s+F\s*/i, '').trim();
if (text) currentSection.questions.push({ type: 'tf', text, correct: '', points: 1 });
continue;
}
// Strip optional leading number: "1. " or "1) "
const numMatch = trimmed.match(/^(\d+)[.)]\s+(.+)/);
const num = numMatch ? Number(numMatch[1]) : undefined;
const content = numMatch ? numMatch[2] : trimmed;
// Fill-in-the-blank: contains 3+ underscores anywhere
if (/_{3,}/.test(content)) {
const template = content.replace(/_{3,}/g, '___');
const blankCount = (template.match(/___/g) || []).length;
currentSection.questions.push({
type: 'fitb',
num,
template,
blanks: Array(blankCount).fill(''),
points: blankCount,
});
continue;
}
// Short answer (numbered or plain)
currentSection.questions.push({ type: 'short', num, text: content, points: 1 });
}
// Drop empty sections
return { hasHeader, sections: sections.filter(s => s.questions.length > 0 || s.title) };
}
function renderWorksheetEditor(wd) {
const container = $('#ie-ws-editor');
container.innerHTML = '';
if (!wd || !wd.sections || wd.sections.length === 0) {
container.innerHTML = '<p class="hint">No questions parsed yet.</p>';
return;
}
let flatQ = 0; // global question counter for unique radio names
wd.sections.forEach((section, si) => {
if (section.title) {
const h = document.createElement('h3');
h.style.cssText = 'margin:1rem 0 .5rem;font-size:.95rem;border-bottom:1px solid var(--border);padding-bottom:.25rem';
h.textContent = section.title;
container.appendChild(h);
}
(section.questions || []).forEach((q, qi) => {
const div = document.createElement('div');
div.className = 'ws-admin-q';
div.style.cssText = 'margin-bottom:.75rem;padding:.6rem .75rem;background:var(--card-bg,#fff);border:1px solid var(--border);border-radius:8px';
if (q.type === 'fitb') {
const label = document.createElement('p');
label.style.cssText = 'margin:0 0 .4rem;font-size:.9rem;color:var(--muted)';
label.textContent = `Fill-in-the-blank${q.num !== undefined ? ` #${q.num}` : ''}`;
div.appendChild(label);
const preview = document.createElement('p');
preview.style.cssText = 'margin:0 0 .5rem;font-size:1rem';
preview.textContent = q.template;
div.appendChild(preview);
q.blanks.forEach((blank, bi) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.35rem';
const lbl = document.createElement('label');
lbl.style.cssText = 'font-size:.85rem;color:var(--muted);white-space:nowrap';
lbl.textContent = `Blank ${bi + 1} answer:`;
const inp = document.createElement('input');
inp.type = 'text';
inp.style.cssText = 'flex:1;min-width:0';
inp.placeholder = 'Correct answer…';
inp.value = blank;
inp.dataset.si = si;
inp.dataset.qi = qi;
inp.dataset.bi = bi;
inp.className = 'ws-blank-ans';
inp.addEventListener('input', () => syncWorksheetData());
row.appendChild(lbl);
row.appendChild(inp);
div.appendChild(row);
});
const ptsRow = document.createElement('div');
ptsRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-top:.25rem';
ptsRow.innerHTML = `<label style="font-size:.8rem;color:var(--muted)">Points total: <input type="number" min="0" step="0.5" value="${q.points}" data-si="${si}" data-qi="${qi}" class="ws-pts-inp" style="width:4rem"></label>`;
ptsRow.querySelector('.ws-pts-inp').addEventListener('input', () => syncWorksheetData());
div.appendChild(ptsRow);
} else if (q.type === 'tf') {
const label = document.createElement('p');
label.style.cssText = 'margin:0 0 .4rem;font-size:.9rem;color:var(--muted)';
label.textContent = 'True / False';
div.appendChild(label);
const stmt = document.createElement('p');
stmt.style.cssText = 'margin:0 0 .5rem;font-size:1rem';
stmt.textContent = q.text;
div.appendChild(stmt);
const radios = document.createElement('div');
radios.style.cssText = 'display:flex;gap:1rem;align-items:center';
const name = `ws-tf-${si}-${qi}-${flatQ}`;
['T', 'F'].forEach(val => {
const lbl = document.createElement('label');
lbl.style.cssText = 'display:flex;align-items:center;gap:.35rem;font-size:.95rem;cursor:pointer';
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = name;
radio.value = val;
radio.dataset.si = si;
radio.dataset.qi = qi;
radio.className = 'ws-tf-radio';
if (q.correct === val) radio.checked = true;
radio.addEventListener('change', () => syncWorksheetData());
lbl.appendChild(radio);
lbl.appendChild(document.createTextNode(val === 'T' ? 'True' : 'False'));
radios.appendChild(lbl);
});
div.appendChild(radios);
} else if (q.type === 'short') {
const label = document.createElement('p');
label.style.cssText = 'margin:0 0 .25rem;font-size:.9rem;color:var(--muted)';
label.textContent = `Short answer${q.num !== undefined ? ` #${q.num}` : ''} — parent grades`;
div.appendChild(label);
const stmt = document.createElement('p');
stmt.style.cssText = 'margin:0 0 .4rem;font-size:1rem';
stmt.textContent = q.text;
div.appendChild(stmt);
const ptsRow = document.createElement('div');
ptsRow.innerHTML = `<label style="font-size:.8rem;color:var(--muted)">Points: <input type="number" min="0" step="1" value="${q.points}" data-si="${si}" data-qi="${qi}" class="ws-pts-inp" style="width:4rem"></label>`;
ptsRow.querySelector('.ws-pts-inp').addEventListener('input', () => syncWorksheetData());
div.appendChild(ptsRow);
}
container.appendChild(div);
flatQ++;
});
});
}
function syncWorksheetData() {
if (!currentWorksheetData) return;
// Sync blank answers from inputs back into currentWorksheetData
document.querySelectorAll('.ws-blank-ans').forEach(inp => {
const si = Number(inp.dataset.si), qi = Number(inp.dataset.qi), bi = Number(inp.dataset.bi);
if (currentWorksheetData.sections[si]?.questions[qi]?.blanks) {
currentWorksheetData.sections[si].questions[qi].blanks[bi] = inp.value.trim();
}
});
document.querySelectorAll('.ws-tf-radio:checked').forEach(radio => {
const si = Number(radio.dataset.si), qi = Number(radio.dataset.qi);
if (currentWorksheetData.sections[si]?.questions[qi]) {
currentWorksheetData.sections[si].questions[qi].correct = radio.value;
}
});
document.querySelectorAll('.ws-pts-inp').forEach(inp => {
const si = Number(inp.dataset.si), qi = Number(inp.dataset.qi);
if (currentWorksheetData.sections[si]?.questions[qi]) {
currentWorksheetData.sections[si].questions[qi].points = Number(inp.value) || 1;
}
});
}
function collectWorksheetAnswers() {
if (!currentWorksheetData) return null;
syncWorksheetData();
return currentWorksheetData;
}
$('#ie-ws-parse').addEventListener('click', () => {
const text = $('#ie-ws-paste').value;
if (!text.trim()) return;
const wd = parseWorksheet(text);
const total = wd.sections.reduce((s, sec) => s + sec.questions.length, 0);
const statusEl = $('#ie-ws-parse-status');
if (total === 0) {
statusEl.textContent = 'No questions found — check the format.';
statusEl.hidden = false;
return;
}
currentWorksheetData = wd;
renderWorksheetEditor(wd);
statusEl.textContent = `Parsed ${total} question${total !== 1 ? 's' : ''}. Fill in any correct answers below, then save.`;
statusEl.hidden = false;
});
// ============================================================ // ============================================================
// Planner // Planner
// ============================================================ // ============================================================
+274 -1
View File
@@ -28,11 +28,12 @@ const todayStr = () => new Date().toISOString().slice(0, 10);
const TYPE_ICON = { const TYPE_ICON = {
lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤', crossword: '⬛', lesson: '📖', assignment: '📝', quiz: '❓', matching: '🔤', crossword: '⬛',
spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️', spelling_practice: '✏️', spelling_test: '⭐', flashcards: '🗂️', worksheet: '📋',
}; };
const TYPE_LABEL = { const TYPE_LABEL = {
lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword', lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword',
spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards', spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards',
worksheet: 'Worksheet',
}; };
document.querySelectorAll('[data-nav]').forEach((btn) => document.querySelectorAll('[data-nav]').forEach((btn) =>
@@ -723,6 +724,7 @@ async function openItem(itemId, type) {
if (type === 'quiz') return openQuiz(itemId, false); if (type === 'quiz') return openQuiz(itemId, false);
if (type === 'matching') return openMatching(itemId, false); if (type === 'matching') return openMatching(itemId, false);
if (type === 'crossword') return openCrossword(itemId); if (type === 'crossword') return openCrossword(itemId);
if (type === 'worksheet') return openWorksheet(itemId);
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId }); if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId });
if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId, allowRetakes: item.allow_retakes }); if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId, allowRetakes: item.allow_retakes });
@@ -817,6 +819,277 @@ async function openAssignment(itemId) {
show('assignment'); show('assignment');
} }
// ---------- worksheet ----------
const wsNormalize = (s) => String(s).trim().toLowerCase();
async function openWorksheet(itemId) {
const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`);
if (!item.worksheetData) return;
const wd = item.worksheetData;
const sub = item.submission;
const graded = sub && sub.status === 'graded';
const submitted = sub && (sub.status === 'done' || sub.status === 'graded');
backTarget = () => openKid(currentStudent.id);
$('#ws-kicker').textContent = `${item.course_name} · ${item.unit_name}`;
$('#ws-title').textContent = item.title;
const headerEl = $('#ws-header');
if (wd.hasHeader) {
headerEl.hidden = false;
$('#ws-date').value = new Date().toLocaleDateString();
$('#ws-name').value = '';
} else {
headerEl.hidden = true;
}
// Build question form
const form = $('#ws-form');
form.innerHTML = '';
wd.sections.forEach((section, si) => {
if (section.title) {
const h = document.createElement('h3');
h.className = 'ws-section-title';
h.textContent = section.title;
form.appendChild(h);
}
(section.questions || []).forEach((q, qi) => {
const div = document.createElement('div');
div.className = 'ws-question';
if (q.type === 'fitb') {
const parts = q.template.split('___');
const numBlanks = q.blankCount || (parts.length - 1);
const p = document.createElement('p');
p.className = 'ws-fitb';
if (q.num !== undefined) {
const numSpan = document.createElement('span');
numSpan.className = 'ws-qnum';
numSpan.textContent = `${q.num}. `;
p.appendChild(numSpan);
}
parts.forEach((part, pi) => {
if (part) p.appendChild(document.createTextNode(part));
if (pi < numBlanks) {
const inp = document.createElement('input');
inp.type = 'text';
inp.className = 'ws-blank-input';
inp.name = `${si}:${qi}:${pi}`;
inp.autocomplete = 'off';
inp.setAttribute('autocapitalize', 'off');
inp.setAttribute('autocorrect', 'off');
inp.spellcheck = false;
if (submitted) inp.disabled = true;
if (graded && q.givenBlanks) {
inp.value = q.givenBlanks[pi] ?? '';
const correct = wsNormalize(q.givenBlanks[pi] ?? '') === wsNormalize((q.blanks || [])[pi] ?? '');
inp.classList.add(correct ? 'ws-correct' : 'ws-wrong');
}
p.appendChild(inp);
if (graded && q.blanks && q.blanks[pi]) {
const ans = document.createElement('span');
ans.className = 'ws-correct-answer';
ans.textContent = ` (${q.blanks[pi]})`;
p.appendChild(ans);
}
}
});
div.appendChild(p);
} else if (q.type === 'tf') {
const p = document.createElement('p');
p.className = 'ws-tf';
const key = `${si}:${qi}`;
['T', 'F'].forEach(val => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ws-tf-btn';
btn.dataset.val = val;
btn.dataset.key = key;
btn.textContent = val;
if (submitted) btn.disabled = true;
if (q.given === val) {
btn.classList.add('selected');
if (graded) btn.classList.add(q.given === q.correct ? 'ws-correct' : 'ws-wrong');
}
p.appendChild(btn);
});
const stmt = document.createElement('span');
stmt.className = 'ws-tf-stmt';
stmt.textContent = q.text;
p.appendChild(stmt);
div.appendChild(p);
} else if (q.type === 'short') {
const label = document.createElement('label');
label.className = 'ws-short';
if (q.num !== undefined) {
const numSpan = document.createElement('span');
numSpan.className = 'ws-qnum';
numSpan.textContent = `${q.num}. `;
label.appendChild(numSpan);
}
label.appendChild(document.createTextNode(q.text));
const ta = document.createElement('textarea');
ta.name = `${si}:${qi}`;
ta.className = 'ws-short-answer';
ta.rows = 3;
ta.placeholder = 'Write your answer here…';
if (submitted) ta.disabled = true;
if (q.given) ta.value = q.given;
label.appendChild(ta);
div.appendChild(label);
}
form.appendChild(div);
});
});
// T/F toggle (only active when not submitted)
form.addEventListener('click', (e) => {
const btn = e.target.closest('.ws-tf-btn');
if (!btn || btn.disabled) return;
const key = btn.dataset.key;
form.querySelectorAll(`.ws-tf-btn[data-key="${key}"]`).forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
});
// Actions
const actions = $('#ws-actions');
const result = $('#ws-result');
const submitBtn = $('#ws-submit-btn');
const printBtn = $('#ws-print-btn');
const paperBtn = $('#ws-paper-btn');
actions.hidden = false;
result.hidden = true;
paperBtn.hidden = submitted; // hide once any submission exists
if (submitted && !graded) {
result.hidden = false;
result.className = 'status-banner';
result.textContent = '⏳ Turned in — waiting for a parent to grade it.';
submitBtn.hidden = true;
} else if (graded) {
result.hidden = false;
result.className = 'status-banner good';
const hasShort = wd.sections.some(s => (s.questions || []).some(q => q.type === 'short'));
result.textContent = hasShort
? `🌟 Score: ${sub.score} / ${sub.points_possible} (short answers parent-graded)`
: `🌟 Score: ${sub.score} / ${sub.points_possible}`;
if (item.allow_retakes) {
submitBtn.hidden = false;
submitBtn.textContent = '🔁 Retake';
} else {
submitBtn.hidden = true;
}
} else {
submitBtn.hidden = false;
submitBtn.textContent = 'Submit ✓';
}
submitBtn.onclick = async () => {
const answers = {};
form.querySelectorAll('.ws-blank-input').forEach(inp => { answers[inp.name] = inp.value.trim(); });
form.querySelectorAll('.ws-tf-btn.selected').forEach(btn => { answers[btn.dataset.key] = btn.dataset.val; });
form.querySelectorAll('.ws-short-answer').forEach(ta => { answers[ta.name] = ta.value.trim(); });
try {
await api(`/api/items/${itemId}/worksheet-submit`, {
method: 'POST',
body: { studentId: currentStudent.id, answers, date: todayStr() },
});
openWorksheet(itemId);
} catch (err) {
result.hidden = false;
result.className = 'status-banner bad';
result.textContent = err.message;
}
};
printBtn.onclick = () => printWorksheetBlank(item);
paperBtn.onclick = async () => {
try {
await api(`/api/items/${itemId}/complete`, {
method: 'POST',
body: { studentId: currentStudent.id, date: todayStr() },
});
openWorksheet(itemId);
} catch (err) {
result.hidden = false;
result.className = 'status-banner bad';
result.textContent = err.message;
}
};
show('worksheet');
}
function printWorksheetBlank(item) {
const wd = item.worksheetData;
if (!wd) return;
const escHtml = (s) => String(s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
let html = `<!DOCTYPE html><html><head><meta charset="UTF-8">
<title>${escHtml(item.title)}</title>
<style>
body{font-family:Georgia,serif;max-width:7in;margin:.75in auto;font-size:12pt;line-height:1.5}
h1{text-align:center;font-size:16pt;margin-bottom:.4in}
.ws-hdr{display:flex;justify-content:space-between;margin-bottom:.5in;font-size:11pt}
.ws-hdr-line{border-bottom:1px solid #000;display:inline-block;width:2.8in}
h3{margin:.35in 0 .1in;font-size:12pt;border-bottom:1px solid #000;padding-bottom:2pt}
.q{margin:.18in 0}
p{margin:.1in 0}
.blank{display:inline-block;border-bottom:1px solid #000;width:1.4in;margin:0 3pt;vertical-align:baseline}
.tf-circle{display:inline-block;border:1px solid #000;border-radius:50%;width:1.1em;height:1.1em;
text-align:center;line-height:1.1em;margin-right:.4em;font-size:11pt}
.sa-line{border-bottom:1px solid #bbb;height:1.5em;margin:.08in 0}
@media print{body{margin:0}}
</style></head><body>`;
html += `<h1>${escHtml(item.title)}</h1>`;
if (wd.hasHeader) {
html += `<div class="ws-hdr">
<div>Name:&nbsp;<span class="ws-hdr-line">&nbsp;</span></div>
<div>Date:&nbsp;<span class="ws-hdr-line" style="width:1.8in">&nbsp;</span></div>
</div>`;
}
wd.sections.forEach(section => {
if (section.title) html += `<h3>${escHtml(section.title)}</h3>`;
(section.questions || []).forEach(q => {
html += `<div class="q">`;
if (q.type === 'fitb') {
const parts = q.template.split('___');
let line = q.num !== undefined ? `${q.num}.&nbsp;` : '';
parts.forEach((part, pi) => {
line += escHtml(part);
if (pi < parts.length - 1) line += `<span class="blank">&nbsp;</span>`;
});
html += `<p>${line}</p>`;
} else if (q.type === 'tf') {
html += `<p><span class="tf-circle">T</span><span class="tf-circle">F</span>&nbsp;&nbsp;${escHtml(q.text)}</p>`;
} else if (q.type === 'short') {
html += `<p>${q.num !== undefined ? `${q.num}.&nbsp;` : ''}${escHtml(q.text)}</p>`;
html += `<div class="sa-line"></div><div class="sa-line"></div><div class="sa-line"></div>`;
}
html += `</div>`;
});
});
html += `</body></html>`;
const win = window.open('', '_blank');
if (win) { win.document.write(html); win.document.close(); win.print(); }
}
// ---------- quiz ---------- // ---------- quiz ----------
async function openQuiz(itemId, forceRetake = false) { async function openQuiz(itemId, forceRetake = false) {
+20
View File
@@ -232,6 +232,26 @@
</div> </div>
</section> </section>
<!-- Worksheet -->
<section id="view-worksheet" class="view" hidden>
<button class="back-btn" data-back="worksheet">← Back</button>
<div class="item-card">
<p class="item-kicker" id="ws-kicker"></p>
<h1 id="ws-title"></h1>
<div id="ws-header" class="ws-header" hidden>
<span>Name: <input id="ws-name" class="ws-name-input" type="text" autocomplete="name" placeholder="Your name"></span>
<span>Date: <input id="ws-date" class="ws-date-input" type="text"></span>
</div>
<form id="ws-form" autocomplete="off"></form>
<div id="ws-result" class="status-banner" hidden></div>
<div class="ws-actions" id="ws-actions" hidden>
<button id="ws-submit-btn" class="check-btn">Submit ✓</button>
<button id="ws-print-btn" class="secondary">🖨 Print blank</button>
<button id="ws-paper-btn" class="secondary">📄 I handed in my paper copy</button>
</div>
</div>
</section>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>
+48 -1
View File
@@ -36,7 +36,8 @@ body {
#view-quiz, #view-quiz,
#view-flashcards, #view-flashcards,
#view-practice, #view-practice,
#view-test { #view-test,
#view-worksheet {
max-width: 960px; max-width: 960px;
text-align: left; text-align: left;
} }
@@ -988,3 +989,49 @@ body.theme-indigo { --accent: #5b6abf; --accent-dark: #4a57a0; --bg: #f4f5ff; }
.cw-cell.correct-word { background: #1a3a1a; } .cw-cell.correct-word { background: #1a3a1a; }
.cw-cell.wrong-word { background: #3a1a1a; } .cw-cell.wrong-word { background: #3a1a1a; }
} }
/* ---------- worksheet ---------- */
.ws-header {
display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: center;
margin-bottom: 1.25rem; font-size: 1rem;
}
.ws-name-input { width: 12rem; }
.ws-date-input { width: 7rem; }
.ws-section-title {
margin: 1.25rem 0 .5rem; font-size: 1rem; font-weight: 700;
border-bottom: 1px solid var(--border); padding-bottom: .3rem;
}
.ws-question { margin-bottom: 1rem; }
.ws-fitb { font-size: 1rem; line-height: 1.8; margin: 0; }
.ws-qnum { font-weight: 700; }
.ws-blank-input {
display: inline-block; width: 7rem; border: none;
border-bottom: 2px solid var(--accent); background: transparent;
font-size: 1rem; font-family: inherit; padding: 0 .25rem;
outline: none; margin: 0 .2rem; vertical-align: baseline;
}
.ws-blank-input:focus { border-bottom-color: var(--accent-dark); }
.ws-blank-input.ws-correct { border-bottom-color: var(--good); color: var(--good); }
.ws-blank-input.ws-wrong { border-bottom-color: var(--bad); color: var(--bad); }
.ws-correct-answer { font-size: .85rem; color: var(--good); font-style: italic; }
.ws-tf { display: flex; align-items: center; gap: .5rem; font-size: 1rem; margin: 0; }
.ws-tf-btn {
min-width: 2.2rem; padding: .35rem .6rem; border: 2px solid var(--border);
border-radius: 6px; background: transparent; font-size: 1rem; font-weight: 700;
cursor: pointer; transition: all .15s; color: inherit;
}
.ws-tf-btn.selected { background: var(--accent); color: #fff; border-color: var(--accent); }
.ws-tf-btn.selected.ws-correct { background: var(--good); border-color: var(--good); }
.ws-tf-btn.selected.ws-wrong { background: var(--bad); border-color: var(--bad); }
.ws-tf-stmt { flex: 1; }
.ws-short { display: block; font-size: 1rem; margin-bottom: 1rem; }
.ws-short-answer {
display: block; width: 100%; margin-top: .4rem; resize: vertical;
border: 1px solid var(--border); border-radius: 8px; padding: .5rem .75rem;
font-family: inherit; font-size: 1rem; background: var(--input-bg, transparent);
}
.ws-actions { display: flex; gap: .75rem; margin-top: 1.25rem; flex-wrap: wrap; }
@media (prefers-color-scheme: dark) {
.ws-blank-input { color: #eee; }
}
+116 -4
View File
@@ -317,8 +317,8 @@ function generateCrossword(acrossEntries, downEntries) {
return { rows, cols, grid, across: mkList('across'), down: mkList('down'), placed: placed.length, total: allEntries.length }; return { rows, cols, grid, across: mkList('across'), down: mkList('down'), placed: placed.length, total: allEntries.length };
} }
const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'matching', 'crossword', 'spelling_practice', 'spelling_test', 'flashcards']; const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'matching', 'crossword', 'spelling_practice', 'spelling_test', 'flashcards', 'worksheet'];
const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling_test']; const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling_test', 'worksheet'];
// ---------- kid picker ---------- // ---------- kid picker ----------
@@ -1017,6 +1017,9 @@ app.get('/api/admin/items/:id', requirePin, (req, res) => {
if (item.type === 'crossword' && item.body) { if (item.type === 'crossword' && item.body) {
try { item.crosswordData = JSON.parse(item.body); } catch { item.crosswordData = null; } try { item.crosswordData = JSON.parse(item.body); } catch { item.crosswordData = null; }
} }
if (item.type === 'worksheet' && item.body) {
try { item.worksheetData = JSON.parse(item.body); } catch { item.worksheetData = null; }
}
res.json(item); res.json(item);
}); });
@@ -1075,6 +1078,12 @@ function validateItemBody(body) {
return 'Quiz needs at least one question'; return 'Quiz needs at least one question';
} }
if (type === 'crossword' && !body.crosswordData) return 'Generate the crossword first'; if (type === 'crossword' && !body.crosswordData) return 'Generate the crossword first';
if (type === 'worksheet') {
const wd = body.worksheetData;
if (!wd || !Array.isArray(wd.sections) || !wd.sections.some(s => Array.isArray(s.questions) && s.questions.length > 0)) {
return 'Parse the worksheet and add at least one question';
}
}
return null; return null;
} }
@@ -1120,6 +1129,12 @@ app.post('/api/items', requirePin, (req, res) => {
const bodyJson = JSON.stringify(cw); const bodyJson = JSON.stringify(cw);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, id); db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, id);
} }
if (type === 'worksheet' && req.body.worksheetData) {
const wd = req.body.worksheetData;
const totalPoints = wd.sections.reduce((sum, s) =>
sum + (s.questions || []).reduce((qs, q) => qs + (Number(q.points) || 1), 0), 0);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(JSON.stringify(wd), totalPoints, id);
}
res.json({ id }); res.json({ id });
}); });
@@ -1145,6 +1160,12 @@ app.put('/api/items/:id', requirePin, (req, res) => {
const bodyJson = JSON.stringify(cw); const bodyJson = JSON.stringify(cw);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, req.params.id); db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, req.params.id);
} }
if (type === 'worksheet' && req.body.worksheetData) {
const wd = req.body.worksheetData;
const totalPoints = wd.sections.reduce((sum, s) =>
sum + (s.questions || []).reduce((qs, q) => qs + (Number(q.points) || 1), 0), 0);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(JSON.stringify(wd), totalPoints, req.params.id);
}
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -1273,6 +1294,43 @@ app.get('/api/items/:id', (req, res) => {
savedAnswers, savedAnswers,
}; };
} catch { item.crosswordData = null; } } catch { item.crosswordData = null; }
} else if (item.type === 'worksheet' && item.body) {
try {
const wd = JSON.parse(item.body);
const graded = item.submission && item.submission.status === 'graded';
const savedAnswers = item.submission && item.submission.answers
? JSON.parse(item.submission.answers) : null;
item.worksheetData = {
hasHeader: wd.hasHeader,
sections: wd.sections.map((section, si) => ({
title: section.title,
questions: section.questions.map((q, qi) => {
const out = { type: q.type, points: q.points };
if (q.num !== undefined) out.num = q.num;
if (q.type === 'fitb') {
out.template = q.template;
out.blankCount = Array.isArray(q.blanks) ? q.blanks.length : 0;
if (graded) {
out.blanks = q.blanks;
out.givenBlanks = Array.from({ length: out.blankCount }, (_, bi) =>
savedAnswers ? (savedAnswers[`${si}:${qi}:${bi}`] ?? '') : '');
}
} else if (q.type === 'tf') {
out.text = q.text;
if (graded) {
out.correct = q.correct;
out.given = savedAnswers ? (savedAnswers[`${si}:${qi}`] ?? '') : '';
}
if (savedAnswers && !graded) out.given = savedAnswers[`${si}:${qi}`] ?? '';
} else if (q.type === 'short') {
out.text = q.text;
out.given = savedAnswers ? (savedAnswers[`${si}:${qi}`] ?? '') : '';
}
return out;
}),
})),
};
} catch { item.worksheetData = null; }
} else if (item.type === 'flashcards') { } else if (item.type === 'flashcards') {
item.deck = db.prepare(`SELECT id, name FROM decks WHERE id = ?`).get(item.ref_id); item.deck = db.prepare(`SELECT id, name FROM decks WHERE id = ?`).get(item.ref_id);
} else if (item.type === 'spelling_practice' || item.type === 'spelling_test') { } else if (item.type === 'spelling_practice' || item.type === 'spelling_test') {
@@ -1399,13 +1457,67 @@ app.post('/api/items/:id/crossword-submit', (req, res) => {
res.json({ score: earned, total: possible }); res.json({ score: earned, total: possible });
}); });
// Auto-grade T/F and FITB; queue short answers for parent grading
app.post('/api/items/:id/worksheet-submit', (req, res) => {
const { studentId, answers, date } = req.body;
const item = db.prepare(`SELECT body FROM items WHERE id = ?`).get(req.params.id);
if (!item || !item.body) return res.status(404).json({ error: 'No such worksheet' });
let wd;
try { wd = JSON.parse(item.body); } catch { return res.status(400).json({ error: 'Invalid worksheet data' }); }
let earned = 0, possible = 0, hasShort = false;
wd.sections.forEach((section, si) => {
(section.questions || []).forEach((q, qi) => {
const pts = Number(q.points) || 1;
possible += pts;
if (q.type === 'tf') {
const given = answers ? answers[`${si}:${qi}`] : undefined;
if (given !== undefined && normalize(given) === normalize(q.correct || '')) earned += pts;
} else if (q.type === 'fitb') {
const blanks = Array.isArray(q.blanks) ? q.blanks : [];
const perBlank = blanks.length > 0 ? pts / blanks.length : 0;
blanks.forEach((correct, bi) => {
const given = answers ? answers[`${si}:${qi}:${bi}`] : undefined;
if (given !== undefined && normalize(given) === normalize(correct || '')) earned += perBlank;
});
} else if (q.type === 'short') {
hasShort = true;
}
});
});
if (hasShort) {
db.prepare(`
INSERT INTO submissions (student_id, item_id, status, score, points_possible, answers, completed_at)
VALUES (?, ?, 'done', ?, ?, ?, datetime('now'))
ON CONFLICT (student_id, item_id) DO UPDATE SET
status = 'done', score = excluded.score, points_possible = excluded.points_possible,
answers = excluded.answers, completed_at = datetime('now'), graded_at = NULL
`).run(studentId, req.params.id, earned, possible, JSON.stringify(answers || {}));
} else {
db.prepare(`
INSERT INTO submissions (student_id, item_id, status, score, points_possible, answers, 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,
answers = excluded.answers, completed_at = datetime('now'), graded_at = datetime('now')
`).run(studentId, req.params.id, earned, possible, JSON.stringify(answers || {}));
}
db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible, answers) VALUES (?, ?, ?, ?, ?)`)
.run(studentId, req.params.id, earned, possible, JSON.stringify(answers || {}));
markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today());
res.json({ score: earned, total: possible, needsGrading: hasShort });
});
// ---------- grading (parent) ---------- // ---------- grading (parent) ----------
app.get('/api/grading-queue/count', requirePin, (req, res) => { app.get('/api/grading-queue/count', requirePin, (req, res) => {
const { n } = db.prepare(` const { n } = db.prepare(`
SELECT COUNT(*) AS n FROM submissions sub SELECT COUNT(*) AS n FROM submissions sub
JOIN items i ON i.id = sub.item_id JOIN items i ON i.id = sub.item_id
WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards') WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards', 'worksheet')
`).get(); `).get();
res.json({ count: n }); res.json({ count: n });
}); });
@@ -1422,7 +1534,7 @@ app.get('/api/grading-queue', requirePin, (req, res) => {
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 IN ('assignment', 'lesson', 'spelling_practice', 'flashcards') WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards', 'worksheet')
ORDER BY sub.completed_at ORDER BY sub.completed_at
`).all()); `).all());
}); });