Add crossword puzzle item type

Admin enters Across/Down words and clues; server auto-generates the grid
using a backtracking placement algorithm (longest-first, intersection-based).
Students fill in the interactive grid with click-to-select, direction toggle,
arrow-key navigation, and Tab-to-next-word. Auto-graded per word on submit,
with correct/wrong color highlighting on the results view. Includes a print
worksheet option for blank paper practice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-08-01 17:56:46 -04:00
1 parent 60bfe3ca4a
commit 682534a061
6 files changed
+680 -6

No files matched your search

+191 -2
View File
@@ -79,8 +79,116 @@ const markScheduleDone = db.prepare(`
WHERE student_id = ? AND item_id = ? AND date = ?
`);
const ITEM_TYPES = ['lesson', 'assignment', 'quiz', 'matching', 'spelling_practice', 'spelling_test', 'flashcards'];
const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'spelling_test'];
// ---------- crossword generator ----------
function generateCrossword(acrossEntries, downEntries) {
const allEntries = [
...acrossEntries.map(e => ({ word: String(e.word || '').toUpperCase().replace(/[^A-Z]/g, ''), clue: e.clue, preferDir: 'across' })),
...downEntries.map(e => ({ word: String(e.word || '').toUpperCase().replace(/[^A-Z]/g, ''), clue: e.clue, preferDir: 'down' })),
].filter(e => e.word.length > 1);
if (allEntries.length === 0) return null;
allEntries.sort((a, b) => b.word.length - a.word.length);
const cells = new Map(); // 'r,c' -> letter
const placed = [];
const dr = (dir) => dir === 'down' ? 1 : 0;
const dc = (dir) => dir === 'across' ? 1 : 0;
function placeWord(word, r, c, dir) {
for (let i = 0; i < word.length; i++) cells.set(`${r + i * dr(dir)},${c + i * dc(dir)}`, word[i]);
}
function canPlace(word, r, c, dir) {
if (cells.has(`${r - dr(dir)},${c - dc(dir)}`)) return false;
if (cells.has(`${r + word.length * dr(dir)},${c + word.length * dc(dir)}`)) return false;
let intersects = false;
for (let i = 0; i < word.length; i++) {
const cr = r + i * dr(dir), cc = c + i * dc(dir);
const existing = cells.get(`${cr},${cc}`);
if (existing !== undefined) {
if (existing !== word[i]) return false;
intersects = true;
} else {
if (cells.has(`${cr - dc(dir)},${cc - dr(dir)}`)) return false;
if (cells.has(`${cr + dc(dir)},${cc + dr(dir)}`)) return false;
}
}
return intersects;
}
function countIntersections(word, r, c, dir) {
let n = 0;
for (let i = 0; i < word.length; i++) if (cells.has(`${r + i * dr(dir)},${c + i * dc(dir)}`)) n++;
return n;
}
const first = allEntries[0];
placeWord(first.word, 0, 0, first.preferDir);
placed.push({ ...first, dir: first.preferDir, row: 0, col: 0 });
for (let i = 1; i < allEntries.length; i++) {
const entry = allEntries[i];
const { word, preferDir } = entry;
const dirs = [preferDir, preferDir === 'across' ? 'down' : 'across'];
let best = null, bestScore = -1;
for (const dir of dirs) {
for (let wi = 0; wi < word.length; wi++) {
for (const [key, letter] of cells) {
if (letter !== word[wi]) continue;
const [er, ec] = key.split(',').map(Number);
const r = er - wi * dr(dir), c = ec - wi * dc(dir);
if (canPlace(word, r, c, dir)) {
const score = countIntersections(word, r, c, dir);
if (score > bestScore) { bestScore = score; best = { r, c, dir }; }
}
}
}
if (best) break;
}
if (best) {
placeWord(word, best.r, best.c, best.dir);
placed.push({ ...entry, dir: best.dir, row: best.r, col: best.c });
}
}
if (placed.length === 0) return null;
let minR = Infinity, minC = Infinity, maxR = -Infinity, maxC = -Infinity;
for (const k of cells.keys()) {
const [r, c] = k.split(',').map(Number);
if (r < minR) minR = r; if (r > maxR) maxR = r;
if (c < minC) minC = c; if (c > maxC) maxC = c;
}
for (const p of placed) { p.row -= minR; p.col -= minC; }
const rows = maxR - minR + 1, cols = maxC - minC + 1;
const grid = Array.from({ length: rows }, () => Array(cols).fill(null));
for (const [k, letter] of cells) {
const [r, c] = k.split(',').map(Number);
grid[r - minR][c - minC] = letter;
}
let num = 1;
const cellNum = {};
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (!grid[r][c]) continue;
const sa = (c === 0 || !grid[r][c - 1]) && c + 1 < cols && grid[r][c + 1];
const sd = (r === 0 || !grid[r - 1][c]) && r + 1 < rows && grid[r + 1][c];
if (sa || sd) cellNum[`${r},${c}`] = num++;
}
}
for (const p of placed) p.num = cellNum[`${p.row},${p.col}`] || null;
const mkList = (dir) => placed.filter(p => p.dir === dir && p.num)
.map(p => ({ num: p.num, clue: p.clue, row: p.row, col: p.col, len: p.word.length, answer: p.word }))
.sort((a, b) => a.num - b.num);
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 GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling_test'];
// ---------- kid picker ----------
@@ -572,6 +680,9 @@ app.get('/api/admin/items/:id', requirePin, (req, res) => {
SELECT id, type, prompt, choices, correct_answer, points FROM quiz_questions WHERE item_id = ? ORDER BY sort, id
`).all(req.params.id).map((q) => ({ ...q, choices: JSON.parse(q.choices) }));
}
if (item.type === 'crossword' && item.body) {
try { item.crosswordData = JSON.parse(item.body); } catch { item.crosswordData = null; }
}
res.json(item);
});
@@ -608,6 +719,14 @@ app.delete('/api/units/:id', requirePin, (req, res) => {
// ---------- items (admin) ----------
app.post('/api/crossword/generate', requirePin, (req, res) => {
const { across, down } = req.body;
if (!Array.isArray(across) && !Array.isArray(down)) return res.status(400).json({ error: 'across and/or down arrays required' });
const result = generateCrossword(across || [], down || []);
if (!result) return res.status(400).json({ error: 'Could not place any words — check that words share common letters' });
res.json(result);
});
function validateItemBody(body) {
const { type, title, refId } = body;
if (!ITEM_TYPES.includes(type)) return 'Invalid item type';
@@ -621,6 +740,7 @@ function validateItemBody(body) {
if (type === 'quiz' && (!Array.isArray(body.questions) || body.questions.length === 0)) {
return 'Quiz needs at least one question';
}
if (type === 'crossword' && !body.crosswordData) return 'Generate the crossword first';
return null;
}
@@ -660,6 +780,12 @@ app.post('/api/items', requirePin, (req, res) => {
const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0);
db.prepare(`UPDATE items SET points = ? WHERE id = ?`).run(totalPoints, id);
}
if (type === 'crossword' && req.body.crosswordData) {
const cw = req.body.crosswordData;
const totalPoints = (cw.across || []).length + (cw.down || []).length;
const bodyJson = JSON.stringify(cw);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, id);
}
res.json({ id });
});
@@ -679,6 +805,12 @@ app.put('/api/items/:id', requirePin, (req, res) => {
dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null,
evidenceMode || 'none', retakePolicy || 'latest', req.params.id);
if (type === 'quiz' || type === 'matching') saveQuizQuestions(req.params.id, req.body.questions);
if (type === 'crossword' && req.body.crosswordData) {
const cw = req.body.crosswordData;
const totalPoints = (cw.across || []).length + (cw.down || []).length;
const bodyJson = JSON.stringify(cw);
db.prepare(`UPDATE items SET body = ?, points = ? WHERE id = ?`).run(bodyJson, totalPoints, req.params.id);
}
res.json({ ok: true });
});
@@ -790,6 +922,23 @@ app.get('/api/items/:id', (req, res) => {
correctLetter: graded ? wordToLetter[q.correct_answer] : undefined,
correctWord: graded ? q.correct_answer : undefined,
}));
} else if (item.type === 'crossword' && item.body) {
try {
const cw = JSON.parse(item.body);
const graded = item.submission && item.submission.status === 'graded';
const savedAnswers = graded && item.submission.answers ? JSON.parse(item.submission.answers) : null;
// Strip answers from grid for students unless graded
const safeGrid = cw.grid.map(row => row.map(cell => cell !== null ? '' : null));
const safeAcross = cw.across.map(({ num, clue, row, col, len }) => ({ num, clue, row, col, len }));
const safeDown = cw.down.map(({ num, clue, row, col, len }) => ({ num, clue, row, col, len }));
item.crosswordData = {
rows: cw.rows, cols: cw.cols,
grid: safeGrid,
across: graded ? cw.across : safeAcross,
down: graded ? cw.down : safeDown,
savedAnswers,
};
} catch { item.crosswordData = null; }
} else if (item.type === 'flashcards') {
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') {
@@ -877,6 +1026,46 @@ app.post('/api/items/:id/quiz-submit', (req, res) => {
res.json({ score: earned, total: possible });
});
// Auto-graded crossword submission
app.post('/api/items/:id/crossword-submit', (req, res) => {
const { studentId, answers, date } = req.body; // answers: {'r,c': 'LETTER'}
const item = db.prepare(`SELECT body, points FROM items WHERE id = ?`).get(req.params.id);
if (!item || !item.body) return res.status(404).json({ error: 'No such crossword' });
let cw;
try { cw = JSON.parse(item.body); } catch { return res.status(400).json({ error: 'Invalid crossword data' }); }
const allWords = [...(cw.across || []), ...(cw.down || [])];
const isDown = (w) => cw.down.some(d => d.num === w.num && d.row === w.row && d.col === w.col);
let earned = 0;
const possible = allWords.length;
for (const w of allWords) {
const dir = cw.across.includes(w) ? 'across' : 'down';
let wordCorrect = true;
for (let i = 0; i < w.len; i++) {
const r = w.row + (dir === 'down' ? i : 0);
const c = w.col + (dir === 'across' ? i : 0);
const given = (answers && answers[`${r},${c}`]) ? String(answers[`${r},${c}`]).toUpperCase() : '';
if (given !== w.answer[i]) { wordCorrect = false; break; }
}
if (wordCorrect) earned++;
}
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 });
});
// ---------- grading (parent) ----------
app.get('/api/grading-queue/count', requirePin, (req, res) => {