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:
nmemmertandClaude Sonnet 4.6 committed 2026-07-21 16:40:07 -04:00
1 parent 39d690bf93
commit 3c22fe05f8
9 files changed
+831 -17

No files matched your search

+4
View File
@@ -224,6 +224,10 @@ tryAlter(`ALTER TABLE schedule ADD COLUMN status TEXT NOT NULL DEFAULT 'not_star
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_notes TEXT`);
tryAlter(`ALTER TABLE words ADD COLUMN definition TEXT NOT NULL DEFAULT ''`);
tryAlter(`ALTER TABLE schedule ADD COLUMN evidence_photo TEXT`);
tryAlter(`ALTER TABLE students ADD COLUMN theme TEXT NOT NULL DEFAULT 'blue'`);
tryAlter(`ALTER TABLE students ADD COLUMN streak_date TEXT`);
tryAlter(`ALTER TABLE students ADD COLUMN streak_count INTEGER NOT NULL DEFAULT 0`);
tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`);
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
+225 -1
View File
@@ -9,12 +9,22 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"express": "^5.1.0"
"express": "^5.1.0",
"mammoth": "^1.12.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -28,6 +38,41 @@
"node": ">= 0.6"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -143,6 +188,12 @@
"node": ">=6.6.0"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -169,6 +220,21 @@
"node": ">= 0.8"
}
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -443,6 +509,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -464,6 +536,68 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -567,6 +701,18 @@
"wrappy": "1"
}
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -576,6 +722,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -586,6 +741,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -643,6 +804,21 @@
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -659,6 +835,12 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -710,6 +892,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -788,6 +976,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -797,6 +991,15 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -837,6 +1040,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -846,6 +1055,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -860,6 +1075,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}
+2 -1
View File
@@ -11,7 +11,8 @@
"author": "NateEmmert",
"license": "ISC",
"dependencies": {
"express": "^5.1.0"
"express": "^5.1.0",
"mammoth": "^1.12.0"
},
"engines": {
"node": ">=24"
+13 -1
View File
@@ -45,6 +45,7 @@
<select id="new-student-emoji" aria-label="Avatar">
<option>🦊</option><option>🐸</option><option>🦄</option><option>🐯</option>
<option>🐼</option><option>🦖</option><option>🐙</option><option>🦉</option>
<option>🐻</option><option>🐰</option><option>🦁</option><option>🐶</option>
</select>
<button type="submit">Add kid</button>
</form>
@@ -149,6 +150,17 @@
<p id="scan-status" class="hint"></p>
</div>
<!-- Import from Homeschool folder: visible for lesson, assignment, quiz types -->
<div id="ie-import-section" class="scan-card" hidden>
<strong>📂 Import from Homeschool folder</strong>
<div class="scan-controls">
<select id="import-file-select" style="flex:1"><option value="">— choose a file —</option></select>
<button type="button" id="import-load-btn" class="secondary">↓ Load files</button>
<button type="button" id="import-go-btn" class="secondary" disabled>📥 Import</button>
</div>
<p id="import-status" class="hint"></p>
</div>
<div id="ie-field-lesson" class="ie-field">
<label>Lesson content (text, or paste HTML — images/YouTube embeds allowed)
<textarea id="ie-body-lesson" rows="8" placeholder="Weather is what the sky and air are doing right now.&#10;&#10;&lt;iframe src=&quot;https://www.youtube.com/embed/VIDEO_ID&quot;&gt;&lt;/iframe&gt;"></textarea>
@@ -258,7 +270,7 @@
<input type="hidden" id="list-id">
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
<textarea id="list-words" rows="8"
placeholder="One word per line. Optionally add sentence and definition with | like:&#10;receive | Did you receive my letter? | to get or accept something"></textarea>
placeholder="One word per line. Paste any list from your curriculum!&#10;&#10;Add an example sentence with |&#10;receive | Did you receive my letter?&#10;&#10;Or just a plain list:&#10;because&#10;enough&#10;friend"></textarea>
<div class="row-actions">
<button type="submit">Save list</button>
<button type="button" id="list-cancel" class="secondary">Clear</button>
+183 -8
View File
@@ -52,6 +52,21 @@ async function unlock() {
$('#pin-gate').hidden = true;
$('#console').hidden = false;
showPanel('kids');
loadGradingBadge();
}
async function loadGradingBadge() {
try {
const { count } = await api('/api/grading-queue/count');
const pill = document.querySelector('.nav-pill[data-panel="grading"]');
if (count > 0) {
pill.textContent = `✅ Grading (${count})`;
pill.classList.add('grading-pending');
} else {
pill.textContent = '✅ Grading';
pill.classList.remove('grading-pending');
}
} catch {}
}
if (parentPin) unlock();
@@ -81,20 +96,41 @@ function showPanel(name) {
// Kids
// ============================================================
const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a' };
async function loadKids() {
const students = await api('/api/students');
$('#student-rows').innerHTML = students.length
? students
.map(
(s) => `<div class="item-row">
<span>${esc(s.emoji)}</span>
.map((s) => {
const theme = s.theme || 'blue';
const swatches = Object.entries(THEME_COLORS)
.map(([t, c]) => `<button class="theme-swatch theme-${t} ${t === theme ? 'selected' : ''}" data-student-theme="${s.id}" data-theme="${t}" title="${t}" style="background:${c}"></button>`)
.join('');
const streak = s.streak_count >= 2 ? `<span style="color:#e8802a;font-size:.85rem">🔥 ${s.streak_count} day streak</span>` : '';
return `<div class="item-row">
<span style="font-size:1.5rem">${esc(s.emoji)}</span>
<strong class="grow">${esc(s.name)}</strong>
${streak}
<div class="theme-swatch-row" title="Kid's color theme">${swatches}</div>
<button class="danger" data-del-student="${s.id}">Remove</button>
</div>`
)
</div>`;
})
.join('')
: `<p class="hint">No kids yet — add one below.</p>`;
document.querySelectorAll('[data-student-theme]').forEach((btn) =>
btn.addEventListener('click', async () => {
const studentId = btn.dataset.studentTheme;
const theme = btn.dataset.theme;
await api(`/api/students/${studentId}`, { method: 'PUT', body: { theme } });
// Update selected state without full reload
document.querySelectorAll(`[data-student-theme="${studentId}"]`).forEach((b) =>
b.classList.toggle('selected', b.dataset.theme === theme)
);
})
);
document.querySelectorAll('[data-del-student]').forEach((btn) =>
btn.addEventListener('click', async () => {
if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return;
@@ -332,6 +368,7 @@ async function openItemEditor(unitId, itemId) {
}
updateItemFieldVisibility();
resetScanSection();
resetImportSection();
showPanel('item-editor');
}
@@ -343,6 +380,7 @@ function updateItemFieldVisibility() {
$('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test';
$('#ie-field-flashcards').hidden = type !== 'flashcards';
$('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz';
$('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' || type === 'flashcards';
}
$('#ie-type').addEventListener('change', updateItemFieldVisibility);
@@ -350,6 +388,12 @@ $('#ie-type').addEventListener('change', updateItemFieldVisibility);
let scanImageBase64 = null;
function resetImportSection() {
$('#import-file-select').innerHTML = '<option value="">— choose a file —</option>';
$('#import-go-btn').disabled = true;
$('#import-status').textContent = '';
}
function resetScanSection() {
scanImageBase64 = null;
$('#scan-file-input').value = '';
@@ -431,6 +475,65 @@ $('#scan-go-btn').addEventListener('click', async () => {
}
});
// ---------- import from Homeschool folder ----------
$('#import-load-btn').addEventListener('click', async () => {
$('#import-status').textContent = 'Loading files…';
$('#import-file-select').innerHTML = '<option value="">— loading… —</option>';
try {
const { files } = await api('/api/admin/homeschool-files');
if (!files.length) {
$('#import-file-select').innerHTML = '<option value="">No .docx files found</option>';
$('#import-status').textContent = '';
return;
}
$('#import-file-select').innerHTML =
'<option value="">— choose a file —</option>' +
files.map((f) => `<option value="${esc(f.path)}">${esc(f.path)}</option>`).join('');
$('#import-go-btn').disabled = false;
$('#import-status').textContent = `${files.length} file(s) found.`;
} catch (err) {
$('#import-status').textContent = `❌ ${err.message}`;
}
});
$('#import-file-select').addEventListener('change', () => {
$('#import-go-btn').disabled = !$('#import-file-select').value;
});
$('#import-go-btn').addEventListener('click', async () => {
const path = $('#import-file-select').value;
if (!path) return;
const type = $('#ie-type').value;
const mode = type === 'quiz' ? 'quiz' : type === 'lesson' ? 'lesson' : 'lesson';
$('#import-go-btn').disabled = true;
$('#import-status').textContent = '🔍 Extracting content… this may take a minute.';
try {
const result = await api('/api/admin/import-docx', { method: 'POST', body: { path, mode } });
if (!$('#ie-title').value && result.title) $('#ie-title').value = result.title;
if (mode === 'quiz' && result.questions) {
$('#ie-questions').innerHTML = '';
questionCount = 0;
const normalized = result.questions.map((q) => ({
...q,
correct_answer: q.correctAnswer ?? q.correct_answer ?? '',
}));
normalized.forEach(addQuestionRow);
$('#import-status').textContent = `✅ Imported ${result.questions.length} question(s). Review before saving.`;
} else if (result.body) {
const bodyField = type === 'assignment' ? '#ie-body-assignment' : '#ie-body-lesson';
$(bodyField).value = result.body;
$('#import-status').textContent = '✅ Content imported. Review before saving.';
} else {
$('#import-status').textContent = '⚠️ File imported but no content was found.';
}
} catch (err) {
$('#import-status').textContent = `❌ ${err.message}`;
} finally {
$('#import-go-btn').disabled = false;
}
});
function addQuestionRow(q = {}) {
const n = questionCount++;
const div = document.createElement('div');
@@ -597,12 +700,12 @@ async function renderPlanner() {
$('#planner-grid').innerHTML = days
.map((day) => {
const dayTasks = tasks.filter((t) => t.date === day.date);
return `<div class="planner-day">
return `<div class="planner-day" data-drop-date="${day.date}">
<h3>${day.name}<small>${day.date}</small></h3>
<div class="planner-tasks">
<div class="planner-tasks" data-drop-date="${day.date}">
${dayTasks
.map(
(t) => `<div class="planner-task ${t.done ? 'done' : ''}">
(t) => `<div class="planner-task ${t.done ? 'done' : ''}" draggable="true" data-task-id="${t.id}">
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
<button class="danger tiny" data-del-schedule="${t.id}">✕</button>
</div>`
@@ -615,6 +718,39 @@ async function renderPlanner() {
})
.join('');
// Drag-and-drop: tasks between days
let draggingId = null;
document.querySelectorAll('.planner-task[draggable]').forEach((el) => {
el.addEventListener('dragstart', (e) => {
draggingId = el.dataset.taskId;
el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
el.addEventListener('dragend', () => {
draggingId = null;
el.classList.remove('dragging');
document.querySelectorAll('.planner-tasks').forEach((col) => col.classList.remove('drop-target'));
});
});
document.querySelectorAll('.planner-tasks').forEach((col) => {
col.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
col.classList.add('drop-target');
});
col.addEventListener('dragleave', () => col.classList.remove('drop-target'));
col.addEventListener('drop', async (e) => {
e.preventDefault();
col.classList.remove('drop-target');
if (!draggingId) return;
const newDate = col.dataset.dropDate;
await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: newDate } });
renderPlanner();
});
});
document.querySelectorAll('[data-del-schedule]').forEach((btn) =>
btn.addEventListener('click', async () => {
await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' });
@@ -768,6 +904,7 @@ async function loadGrading() {
body: { score: Number(input.value), parentComment: commentInput?.value || '' },
});
loadGrading();
loadGradingBadge();
});
});
}
@@ -886,12 +1023,20 @@ function renderLists(lists) {
(l) => `<div class="item-row">
<strong class="grow">${esc(l.name)}</strong>
<span>${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>
<button data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
${l.builtin ? '' : `<button class="danger" data-del-list="${l.id}">Delete</button>`}
</div>`
)
.join('');
document.querySelectorAll('[data-print-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.printList}`);
printSpellingWorksheet(list.name, list.words);
})
);
document.querySelectorAll('[data-edit-list]').forEach((btn) =>
btn.addEventListener('click', async () => {
const list = await api(`/api/lists/${btn.dataset.editList}`);
@@ -966,6 +1111,36 @@ function renderResults(students) {
document.querySelectorAll('[data-print]').forEach((btn) => btn.addEventListener('click', () => printSpellingReport(btn.dataset.print)));
}
function printSpellingWorksheet(name, words) {
const rows = words
.map((w) => {
const sentence = w.sentence ? `<div style="color:#888;font-size:.85em;font-style:italic">${esc(w.sentence)}</div>` : '';
return `<tr>
<td style="font-weight:700;font-size:1.1em;padding:.6rem .75rem">${esc(w.word)}${sentence}</td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
</tr>`;
})
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Spelling: ${esc(name)}</title>
<style>
body { font-family: Georgia, serif; max-width: 680px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.4rem; border-bottom: 2px solid #222; padding-bottom: .4rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
td { padding: .55rem .75rem; }
th { text-align: left; color: #555; font-size: .85rem; font-weight: 600; padding: .4rem .75rem; }
</style></head><body>
<h1>✏️ Spelling: ${esc(name)}</h1>
<p style="color:#555;font-size:.9em">Write each word twice — once to practice, once from memory.</p>
<table>
<tr><th>Word</th><th>Practice</th><th>From memory</th></tr>
${rows}
</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
async function printSpellingReport(testId) {
const r = await api(`/api/test-report/${testId}`);
const rows = r.answers
+135 -3
View File
@@ -183,16 +183,41 @@ function diffLetters(typed, correct) {
return { typedHtml: typedParts.join(''), correctHtml: correctParts.join('') };
}
// ---------- themes ----------
const THEMES = {
blue: {},
green: { '--accent': '#2e9e5b', '--accent-dark': '#247a47', '--bg': '#f0faf4' },
purple: { '--accent': '#7c5cbf', '--accent-dark': '#6245a0', '--bg': '#f8f4ff' },
orange: { '--accent': '#e8802a', '--accent-dark': '#c96a1a', '--bg': '#fff8f0' },
pink: { '--accent': '#d45d8a', '--accent-dark': '#b4487a', '--bg': '#fff0f5' },
};
const THEME_NAMES = Object.keys(THEMES);
const DEFAULT_VARS = { '--accent': '#4f86f7', '--accent-dark': '#3a6fd8', '--bg': '#fdf6e3' };
function applyTheme(theme) {
const vars = THEMES[theme] || {};
for (const [k, v] of Object.entries(DEFAULT_VARS))
document.documentElement.style.setProperty(k, vars[k] || v);
document.body.className = theme && theme !== 'blue' ? `theme-${theme}` : '';
}
function resetTheme() {
for (const k of Object.keys(DEFAULT_VARS)) document.documentElement.style.removeProperty(k);
document.body.className = '';
}
// ---------- home (kid picker) ----------
let currentStudent = null;
async function loadHome() {
resetTheme();
const students = await api('/api/students');
$('#no-kids').hidden = students.length > 0;
$('#kid-cards').innerHTML = students
.map(
(s) => `<button class="kid-card" data-id="${s.id}">
(s) => `<button class="kid-card" data-id="${s.id}" data-theme="${esc(s.theme || 'blue')}">
<span class="avatar">${esc(s.emoji)}</span>${esc(s.name)}</button>`
)
.join('');
@@ -219,15 +244,78 @@ async function openKid(id, tab = 'today') {
api(`/api/courses/mine/${id}`),
]);
currentStudent = state.student;
applyTheme(currentStudent.theme || 'blue');
updateThemeDots(currentStudent.theme || 'blue');
$('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`;
// Show streak pill if kid has a streak
const existingPill = document.querySelector('.streak-pill');
if (existingPill) existingPill.remove();
if (state.student.streak_count >= 2) {
const pill = document.createElement('div');
pill.className = 'streak-pill';
pill.textContent = `🔥 ${state.student.streak_count} day streak!`;
$('#kid-greeting').insertAdjacentElement('afterend', pill);
}
renderToday(agenda.tasks);
renderCourseCards(courses);
renderSpellingTab(state);
show('kid');
switchTab(tab);
// Celebrate if all today's tasks are done
if (agenda.tasks.length > 0) {
const allDone = agenda.tasks.every((t) =>
t.done || t.offlineStatus === 'done' || t.subStatus === 'graded' || t.subStatus === 'done'
);
if (allDone) {
const result = await api(`/api/students/${id}/complete-day`, { method: 'POST' });
if (!result.alreadyCounted) showCelebration(result);
}
}
}
function updateThemeDots(active) {
document.querySelectorAll('.theme-dot').forEach((btn) =>
btn.classList.toggle('active', btn.dataset.theme === active)
);
}
function showCelebration({ streak, best }) {
const isRecord = streak > 1 && streak === best;
let streakHtml = '';
if (streak >= 2) {
streakHtml = `<div class="streak-badge">🔥 ${streak} days in a row!</div>
${isRecord ? `<div class="best-streak-label">🏆 New record!</div>` : `<div class="streak-label">Best: ${best} days</div>`}`;
} else {
streakHtml = `<div class="streak-label" style="margin-bottom:1rem">Keep it up — come back tomorrow for a streak!</div>`;
}
$('#streak-display').innerHTML = streakHtml;
$('#celebration-overlay').hidden = false;
}
$('#celebration-close').addEventListener('click', () => {
$('#celebration-overlay').hidden = true;
});
// Theme picker toggle
$('#theme-picker-btn').addEventListener('click', () => {
$('#theme-dots').hidden = !$('#theme-dots').hidden;
});
document.querySelectorAll('.theme-dot').forEach((btn) =>
btn.addEventListener('click', async () => {
const theme = btn.dataset.theme;
applyTheme(theme);
updateThemeDots(theme);
currentStudent.theme = theme;
await api(`/api/students/${currentStudent.id}/theme`, { method: 'PATCH', body: { theme } });
$('#theme-dots').hidden = true;
})
);
const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' };
@@ -263,9 +351,10 @@ function renderToday(tasks) {
</div>`;
}
const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done);
const feedbackBadge = t.parentComment ? `<span class="feedback-badge">💬 feedback</span>` : '';
return `<button class="today-row" data-item-id="${t.itemId}" data-item-type="${t.type}">
<span class="row-check">${t.done ? '✅' : TYPE_ICON[t.type]}</span>
<span class="row-title">${esc(t.itemTitle)}<small>${esc(t.courseName)}</small></span>
<span class="row-title">${esc(t.itemTitle)}${feedbackBadge}<small>${esc(t.courseName)}</small></span>
<span class="row-badge">${badge}</span>
</button>`;
})
@@ -341,6 +430,9 @@ function renderCourseCards(courses) {
);
}
let currentSpellingListId = null;
let currentSpellingListName = '';
function renderSpellingTab(state) {
let html;
if (state.assignment) {
@@ -348,14 +440,54 @@ function renderSpellingTab(state) {
html = `<strong>This week: ${esc(state.assignment.name)}</strong>
<div class="mastery-dots">${'🌟'.repeat(mastered)}${'⚪'.repeat(Math.max(0, total - mastered))}</div>
<div>${mastered} of ${total} words mastered</div>`;
currentSpellingListId = state.assignment.id;
currentSpellingListName = state.assignment.name;
} else {
html = `No list assigned yet — ask a parent to pick one!`;
currentSpellingListId = null;
}
if (state.dueReviews > 0) {
html += `<div>🔁 ${state.dueReviews} old ${state.dueReviews === 1 ? 'word' : 'words'} due for review</div>`;
}
$('#kid-week').innerHTML = html;
$('#btn-test').disabled = !state.assignment;
$('#btn-print-list').disabled = !state.assignment;
}
$('#btn-print-list').addEventListener('click', async () => {
if (!currentSpellingListId) return;
const list = await api(`/api/lists/${currentSpellingListId}`);
printSpellingWorksheet(list.name, list.words);
});
function printSpellingWorksheet(name, words) {
const rows = words
.map((w) => {
const sentence = w.sentence ? `<div style="color:#888;font-size:.85em;font-style:italic">${esc(w.sentence)}</div>` : '';
return `<tr>
<td style="font-weight:700;font-size:1.1em;padding:.6rem .75rem">${esc(w.word)}${sentence}</td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
<td style="border-bottom:1.5px solid #aaa;width:35%"></td>
</tr>`;
})
.join('');
const win = window.open('', '_blank');
win.document.write(`<!DOCTYPE html><html><head><title>Spelling: ${esc(name)}</title>
<style>
body { font-family: Georgia, serif; max-width: 680px; margin: 2rem auto; color: #222; }
h1 { font-size: 1.4rem; border-bottom: 2px solid #222; padding-bottom: .4rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
td { padding: .55rem .75rem; }
th { text-align: left; color: #555; font-size: .85rem; font-weight: 600; padding: .4rem .75rem; }
</style></head><body>
<h1>✏️ Spelling: ${esc(name)}</h1>
<p style="color:#555;font-size:.9em">Write each word twice — once to practice, once from memory.</p>
<table>
<tr><th>Word</th><th>Practice</th><th>From memory</th></tr>
${rows}
</table>
<script>window.print()<\/script></body></html>`);
win.document.close();
}
// ---------- course detail ----------
@@ -450,7 +582,7 @@ async function openAssignment(itemId) {
// Show parent comment if graded
if (item.submission?.parent_comment) {
commentEl.hidden = false;
commentEl.innerHTML = `<strong>Parent feedback:</strong> ${esc(item.submission.parent_comment)}`;
commentEl.innerHTML = `<strong>💬 Feedback from your parent:</strong><br>${esc(item.submission.parent_comment)}`;
} else {
commentEl.hidden = true;
}
+21
View File
@@ -19,8 +19,18 @@
<!-- Kid home: Today's work + nav -->
<section id="view-kid" class="view" hidden>
<div class="kid-topbar">
<button class="back-btn" data-nav="home">← Switch</button>
<button id="theme-picker-btn" class="theme-picker-btn" aria-label="Pick my color">🎨</button>
</div>
<h1 id="kid-greeting"></h1>
<div id="theme-dots" class="theme-dots" hidden>
<button class="theme-dot theme-blue" data-theme="blue" title="Blue"></button>
<button class="theme-dot theme-green" data-theme="green" title="Green"></button>
<button class="theme-dot theme-purple" data-theme="purple" title="Purple"></button>
<button class="theme-dot theme-orange" data-theme="orange" title="Orange"></button>
<button class="theme-dot theme-pink" data-theme="pink" title="Pink"></button>
</div>
<div class="kid-nav">
<button class="nav-pill active" data-tab="today">📋 Today</button>
@@ -43,10 +53,21 @@
<div class="big-actions">
<button id="btn-practice" class="big-btn practice">✏️ Practice</button>
<button id="btn-test" class="big-btn test">⭐ Friday Test</button>
<button id="btn-print-list" class="big-btn print-list">🖨 Print my words</button>
</div>
</div>
</section>
<!-- All-done celebration overlay -->
<div id="celebration-overlay" hidden>
<div id="celebration-content">
<div class="celebration-emoji">🎉</div>
<h2>All done for today!</h2>
<div id="streak-display"></div>
<button id="celebration-close" class="check-btn">High five! ✋</button>
</div>
</div>
<!-- Course detail: units + items -->
<section id="view-course" class="view" hidden>
<button class="back-btn" data-back="course">← Back</button>
+102
View File
@@ -380,6 +380,14 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
background: #f7f2e3; border-radius: 8px; padding: 0.4rem 0.6rem; font-size: 0.85rem;
}
.planner-task.done { opacity: 0.55; text-decoration: line-through; }
.planner-task[draggable] { cursor: grab; }
.planner-task[draggable]:active { cursor: grabbing; }
.planner-task.dragging { opacity: 0.35; }
.planner-tasks.drop-target {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-radius: 8px;
outline: 2px dashed var(--accent);
}
.add-task-form { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; }
.add-task-form select, .add-task-form input { font-size: 0.85rem; padding: 0.4rem; border: 2px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.or-divider { text-align: center; font-size: 0.75rem; color: var(--muted); }
@@ -477,3 +485,97 @@ td.overdue, .overdue { background: var(--bad-bg) !important; }
.grading-footer { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.6rem; }
.comment-input { flex: 1; min-width: 140px; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; }
.badge-tag { background: var(--gold); color: #333; border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.8rem; font-weight: 600; }
/* ---------- themes ---------- */
body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; }
body.theme-purple { --accent: #7c5cbf; --accent-dark: #6245a0; --bg: #f8f4ff; }
body.theme-orange { --accent: #e8802a; --accent-dark: #c96a1a; --bg: #fff8f0; }
body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; }
/* ---------- kid top bar + theme picker ---------- */
.kid-topbar { display: flex; justify-content: space-between; align-items: center; }
.theme-picker-btn {
background: none; border: none; font-size: 1.3rem; cursor: pointer;
padding: 0.25rem 0.5rem; color: var(--muted); line-height: 1;
}
.theme-dots {
display: flex; gap: 0.6rem; justify-content: flex-end;
margin-bottom: 0.75rem;
}
.theme-dot {
width: 2rem; height: 2rem; border-radius: 50%;
border: 3px solid transparent; cursor: pointer;
transition: transform 0.12s, border-color 0.12s;
}
.theme-dot:hover { transform: scale(1.2); }
.theme-dot.active { border-color: var(--ink); box-shadow: 0 0 0 2px var(--bg); }
.theme-dot.theme-blue { background: #4f86f7; }
.theme-dot.theme-green { background: #2e9e5b; }
.theme-dot.theme-purple { background: #7c5cbf; }
.theme-dot.theme-orange { background: #e8802a; }
.theme-dot.theme-pink { background: #d45d8a; }
/* ---------- celebration overlay ---------- */
#celebration-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0, 0, 0, 0.55);
display: flex; align-items: center; justify-content: center;
}
#celebration-content {
background: var(--card); border-radius: var(--radius);
padding: 2.5rem 2rem; text-align: center;
max-width: 360px; width: 92%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
animation: pop-in 0.25s ease;
}
@keyframes pop-in { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.celebration-emoji { font-size: 5rem; animation: bounce 0.6s ease infinite alternate; display: block; }
@keyframes bounce { to { transform: translateY(-14px); } }
#celebration-content h2 { font-size: 1.8rem; margin: 0.5rem 0 1rem; }
.streak-badge { font-size: 2.6rem; font-weight: 800; color: var(--accent); }
.streak-label { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
.best-streak-label { font-size: 0.85rem; color: var(--gold); font-weight: 700; margin-bottom: 0.5rem; }
/* ---------- streak pill on kid home ---------- */
.streak-pill {
display: inline-flex; align-items: center; gap: 0.3rem;
background: var(--good-bg); color: var(--good);
border-radius: 999px; padding: 0.25rem 0.9rem;
font-size: 0.95rem; font-weight: 700; margin-bottom: 0.75rem;
}
/* ---------- feedback indicator ---------- */
.feedback-badge {
display: inline-block; background: var(--gold); color: #333;
font-size: 0.7rem; font-weight: 800; border-radius: 999px;
padding: 0.1rem 0.45rem; margin-left: 0.35rem; vertical-align: middle;
}
.parent-comment {
background: var(--good-bg); border-radius: 10px;
padding: 0.9rem 1rem; margin: 0.75rem 0; color: var(--good);
border-left: 4px solid var(--good);
font-size: 1.05rem;
}
/* ---------- print list button ---------- */
.big-btn.print-list {
background: #6b7280; color: #fff; font-size: 1.2rem;
}
/* ---------- grading badge on admin nav ---------- */
.nav-pill.grading-pending { background: var(--bad); color: #fff; border-color: var(--bad); }
/* ---------- admin theme swatches ---------- */
.theme-swatch-row { display: flex; gap: 0.4rem; align-items: center; }
.theme-swatch {
width: 1.5rem; height: 1.5rem; border-radius: 50%;
border: 2px solid transparent; cursor: pointer;
transition: transform 0.1s;
}
.theme-swatch:hover { transform: scale(1.2); }
.theme-swatch.selected { border-color: var(--ink); }
.theme-swatch.theme-blue { background: #4f86f7; }
.theme-swatch.theme-green { background: #2e9e5b; }
.theme-swatch.theme-purple { background: #7c5cbf; }
.theme-swatch.theme-orange { background: #e8802a; }
.theme-swatch.theme-pink { background: #d45d8a; }
+146 -3
View File
@@ -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;