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:
1 parent
9abb107096
commit
6f73acd03e
6 files changed
+720
-8
No files matched your search
@@ -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: • Name: ____ Date: ____ → header line • Part 1 — Title → section header • 1. Sentence with _____ blanks. → fill-in-the-blank • T F Statement text → true/false question • 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
@@ -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
@@ -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 =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: <span class="ws-hdr-line"> </span></div>
|
||||||
|
<div>Date: <span class="ws-hdr-line" style="width:1.8in"> </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}. ` : '';
|
||||||
|
parts.forEach((part, pi) => {
|
||||||
|
line += escHtml(part);
|
||||||
|
if (pi < parts.length - 1) line += `<span class="blank"> </span>`;
|
||||||
|
});
|
||||||
|
html += `<p>${line}</p>`;
|
||||||
|
} else if (q.type === 'tf') {
|
||||||
|
html += `<p><span class="tf-circle">T</span><span class="tf-circle">F</span> ${escHtml(q.text)}</p>`;
|
||||||
|
} else if (q.type === 'short') {
|
||||||
|
html += `<p>${q.num !== undefined ? `${q.num}. ` : ''}${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) {
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user