Add kid engagement features and planner drag-and-drop
- Streak tracker: server-side streak counting per kid, celebration overlay when all today's tasks are done for the first time that day - Kid themes: 5 colour palettes (blue/green/purple/orange/pink), picked via colour dots on the kid home screen; parents can set it from the Kids panel - Grading badge: Grading nav pill turns red and shows a pending count - Print worksheets: 🖨 Worksheet button per word list in admin Spelling panel; kids also get "Print my words" on the Spelling tab - Parent feedback: gold 💬 badge on Today rows with unread comments; more prominent callout styling inside the assignment view - Planner drag-and-drop: tasks can be dragged between days; PATCH /api/schedule/:id moves the task, drop zones highlight on hover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
39d690bf93
commit
3c22fe05f8
9 files changed
+832
-18
No files matched your search
@@ -2,8 +2,10 @@ import express from 'express';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import mammoth from 'mammoth';
|
||||
import { db, sha256 } from './db.js';
|
||||
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -81,7 +83,28 @@ const GRADABLE_TYPES = ['assignment', 'quiz', 'spelling_test'];
|
||||
// ---------- kid picker ----------
|
||||
|
||||
app.get('/api/students', (req, res) => {
|
||||
res.json(db.prepare(`SELECT id, name, emoji FROM students ORDER BY name`).all());
|
||||
res.json(db.prepare(`SELECT id, name, emoji, theme, streak_count, streak_date FROM students ORDER BY name`).all());
|
||||
});
|
||||
|
||||
app.patch('/api/students/:id/theme', (req, res) => {
|
||||
const theme = ['blue','green','purple','orange','pink'].includes(req.body.theme) ? req.body.theme : 'blue';
|
||||
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/students/:id/complete-day', (req, res) => {
|
||||
const student = db.prepare(`SELECT streak_date, streak_count, best_streak FROM students WHERE id = ?`).get(req.params.id);
|
||||
if (!student) return res.status(404).json({ error: 'Not found' });
|
||||
const t = today();
|
||||
if (student.streak_date === t) return res.json({ streak: student.streak_count, best: student.best_streak, alreadyCounted: true });
|
||||
const d = new Date(t + 'T00:00:00');
|
||||
d.setDate(d.getDate() - 1);
|
||||
const yesterday = d.toISOString().slice(0, 10);
|
||||
const newStreak = student.streak_date === yesterday ? student.streak_count + 1 : 1;
|
||||
const newBest = Math.max(newStreak, student.best_streak);
|
||||
db.prepare(`UPDATE students SET streak_date = ?, streak_count = ?, best_streak = ? WHERE id = ?`)
|
||||
.run(t, newStreak, newBest, req.params.id);
|
||||
res.json({ streak: newStreak, best: newBest, alreadyCounted: false });
|
||||
});
|
||||
|
||||
// ---------- spelling module ----------
|
||||
@@ -377,6 +400,14 @@ app.post('/api/students', requirePin, (req, res) => {
|
||||
res.json({ id });
|
||||
});
|
||||
|
||||
app.put('/api/students/:id', requirePin, (req, res) => {
|
||||
const { emoji, theme } = req.body;
|
||||
if (emoji) db.prepare(`UPDATE students SET emoji = ? WHERE id = ?`).run(String(emoji), req.params.id);
|
||||
if (theme && ['blue','green','purple','orange','pink'].includes(theme))
|
||||
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/students/:id', requirePin, (req, res) => {
|
||||
db.prepare(`DELETE FROM students WHERE id = ?`).run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
@@ -757,6 +788,15 @@ app.post('/api/items/:id/quiz-submit', (req, res) => {
|
||||
|
||||
// ---------- grading (parent) ----------
|
||||
|
||||
app.get('/api/grading-queue/count', requirePin, (req, res) => {
|
||||
const { n } = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM submissions sub
|
||||
JOIN items i ON i.id = sub.item_id
|
||||
WHERE sub.status = 'done' AND i.type IN ('assignment', 'lesson', 'spelling_practice', 'flashcards')
|
||||
`).get();
|
||||
res.json({ count: n });
|
||||
});
|
||||
|
||||
app.get('/api/grading-queue', requirePin, (req, res) => {
|
||||
res.json(db.prepare(`
|
||||
SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible,
|
||||
@@ -949,7 +989,7 @@ app.get('/api/schedule/:studentId', (req, res) => {
|
||||
sc.evidence_notes IS NOT NULL OR sc.evidence_photo IS NOT NULL AS hasEvidence,
|
||||
i.id AS itemId, i.type, i.title AS itemTitle,
|
||||
c.name AS courseName, c.color AS courseColor,
|
||||
sub.status AS subStatus, sub.score, sub.points_possible
|
||||
sub.status AS subStatus, sub.score, sub.points_possible, sub.parent_comment AS parentComment
|
||||
FROM schedule sc
|
||||
LEFT JOIN items i ON i.id = sc.item_id
|
||||
LEFT JOIN units u ON u.id = i.unit_id
|
||||
@@ -1039,6 +1079,13 @@ app.post('/api/schedule', requirePin, (req, res) => {
|
||||
res.json({ id });
|
||||
});
|
||||
|
||||
app.patch('/api/schedule/:id', requirePin, (req, res) => {
|
||||
const { date } = req.body;
|
||||
if (!isDateStr(date)) return res.status(400).json({ error: 'date=YYYY-MM-DD required' });
|
||||
db.prepare(`UPDATE schedule SET date = ? WHERE id = ?`).run(date, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/schedule/:id', requirePin, (req, res) => {
|
||||
db.prepare(`DELETE FROM schedule WHERE id = ?`).run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
@@ -1319,6 +1366,102 @@ app.get('/api/tts', (req, res) => {
|
||||
res.send(readFileSync(cachePath));
|
||||
});
|
||||
|
||||
// ---------- Homeschool folder import ----------
|
||||
|
||||
const HOMESCHOOL_DIR = "/Users/nateemmert/Library/CloudStorage/SeaDrive-NateEmmert(seafile.necloud.us)/My Libraries/Homeschool";
|
||||
|
||||
function listDocxFiles(dir, base = '') {
|
||||
const results = [];
|
||||
let entries;
|
||||
try { entries = readdirSync(dir); } catch { return results; }
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let stat;
|
||||
try { stat = statSync(full); } catch { continue; }
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...listDocxFiles(full, base ? `${base}/${name}` : name));
|
||||
} else if (name.toLowerCase().endsWith('.docx') && !name.startsWith('~$')) {
|
||||
results.push({ name, path: base ? `${base}/${name}` : name, full });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
app.get('/api/admin/homeschool-files', requirePin, (req, res) => {
|
||||
const files = listDocxFiles(HOMESCHOOL_DIR);
|
||||
res.json({ files: files.map(({ name, path }) => ({ name, path })) });
|
||||
});
|
||||
|
||||
const TEXT_SCAN_PROMPTS = {
|
||||
lesson: `You are given the text content of a homeschool lesson or assignment document. Extract it as structured content.
|
||||
|
||||
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
|
||||
{"title": "the document title or heading", "body": "the full text content, paragraphs separated by a blank line"}`,
|
||||
|
||||
quiz: `You are given the text content of a homeschool quiz or test document. Extract every question exactly as written.
|
||||
|
||||
For each question determine:
|
||||
- "type": "mc" if it has lettered/numbered answer choices, "tf" if it is true/false, "short" if it expects a written answer
|
||||
- "prompt": the exact question text
|
||||
- "choices": for "mc", an array of the exact answer choice texts (empty array for "tf" or "short")
|
||||
- "correctAnswer": the marked answer if clearly indicated; for "tf" use exactly "true" or "false"; otherwise use ""
|
||||
- "points": 1 unless the document states a different value
|
||||
|
||||
Respond with ONLY a JSON object in exactly this shape, nothing else, no markdown fences:
|
||||
{"title": "the quiz/test title", "questions": [{"type": "mc", "prompt": "...", "choices": ["...", "..."], "correctAnswer": "...", "points": 1}]}`,
|
||||
};
|
||||
|
||||
app.post('/api/admin/import-docx', requirePin, async (req, res) => {
|
||||
const { path: relPath, mode } = req.body;
|
||||
if (!relPath || !TEXT_SCAN_PROMPTS[mode]) {
|
||||
return res.status(400).json({ error: 'path and mode (lesson or quiz) required' });
|
||||
}
|
||||
const fullPath = join(HOMESCHOOL_DIR, relPath);
|
||||
if (!fullPath.startsWith(HOMESCHOOL_DIR)) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
let buffer;
|
||||
try { buffer = await readFile(fullPath); }
|
||||
catch (err) { return res.status(404).json({ error: `Could not read file: ${err.message}` }); }
|
||||
|
||||
let docText;
|
||||
try {
|
||||
const result = await mammoth.extractRawText({ buffer });
|
||||
docText = result.value.trim();
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: `Could not parse docx: ${err.message}` });
|
||||
}
|
||||
if (!docText) return res.status(400).json({ error: 'Document appears to be empty' });
|
||||
|
||||
const host = (getSetting('ollama_host') || 'http://localhost:11434').replace(/\/+$/, '');
|
||||
const model = getSetting('ollama_model') || 'llava';
|
||||
const prompt = TEXT_SCAN_PROMPTS[mode] + '\n\nDOCUMENT TEXT:\n' + docText;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 180_000);
|
||||
try {
|
||||
const r = await fetch(`${host}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, prompt, stream: false, format: 'json' }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text().catch(() => '');
|
||||
throw new Error(`Ollama error (${r.status}): ${t.slice(0, 200)}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
const parsed = parseVisionJson(data.response);
|
||||
return res.json(parsed);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return res.status(504).json({ error: 'Ollama timed out.' });
|
||||
return res.status(502).json({ error: err.message });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
Reference in new issue
Block a user