Compare commits
15
Commits
a92db291c7
..
main
No files matched your search
@@ -244,6 +244,19 @@ tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||
tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||
tryAlter(`ALTER TABLE tests ADD COLUMN item_id INTEGER`);
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT 'Untitled Document',
|
||||
doc_key TEXT NOT NULL UNIQUE,
|
||||
filename TEXT NOT NULL,
|
||||
submitted INTEGER NOT NULL DEFAULT 0,
|
||||
submitted_at TEXT,
|
||||
parent_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
|
||||
|
||||
// Default parent PIN on first run
|
||||
|
||||
Generated
+45
-1
@@ -9,9 +9,11 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dictionary-en": "^4.0.0",
|
||||
"express": "^5.1.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"multer": "^2.2.0"
|
||||
"multer": "^2.2.0",
|
||||
"nspell": "^2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
@@ -273,6 +275,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dictionary-en": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dictionary-en/-/dictionary-en-4.0.0.tgz",
|
||||
"integrity": "sha512-3NHnE1uq33ZE/CIwaZ6gqxa4BnglHnxeAcTM0GJ7cmtRGcvX9InMK/IqLtYcUMFCUpUNgybH+DzkqdgAo3F1zg==",
|
||||
"license": "(MIT AND BSD)",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
@@ -583,6 +595,29 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||
"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",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@@ -783,6 +818,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nspell": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/nspell/-/nspell-2.1.5.tgz",
|
||||
"integrity": "sha512-PSStyugKMiD9mHmqI/CR5xXrSIGejUXPlo88FBRq5Og1kO5QwQ5Ilu8D8O5I/SHpoS+mibpw6uKA8rd3vXd2Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-buffer": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
|
||||
+3
-1
@@ -11,9 +11,11 @@
|
||||
"author": "NateEmmert",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dictionary-en": "^4.0.0",
|
||||
"express": "^5.1.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"multer": "^2.2.0"
|
||||
"multer": "^2.2.0",
|
||||
"nspell": "^2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
|
||||
+120
-37
@@ -21,25 +21,83 @@
|
||||
</section>
|
||||
|
||||
<div id="console" hidden>
|
||||
<header class="admin-header">
|
||||
<a href="index.html" class="admin-logo" id="admin-school-name">🎓 Homeschool</a>
|
||||
<aside class="admin-sidebar">
|
||||
<a href="index.html" class="admin-logo">
|
||||
<span class="admin-logo-icon">🏫</span>
|
||||
<span id="admin-school-name">Homeschool</span>
|
||||
</a>
|
||||
<nav class="admin-nav">
|
||||
<button class="nav-pill active" data-panel="kids">🧒 Kids</button>
|
||||
<button class="nav-pill" data-panel="courses">📚 Courses</button>
|
||||
<button class="nav-pill" data-panel="planner">🗓️ Planner</button>
|
||||
<button class="nav-pill" data-panel="grading">✅ Grading</button>
|
||||
<button class="nav-pill" data-panel="messages">💬 Messages</button>
|
||||
<button class="nav-pill" data-panel="gradebook">📊 Gradebook</button>
|
||||
<button class="nav-pill" data-panel="spelling">✏️ Spelling</button>
|
||||
<button class="nav-pill" data-panel="decks">🗂️ Flashcards</button>
|
||||
<button class="nav-pill" data-panel="settings">⚙️ Settings</button>
|
||||
<button class="nav-pill active" data-panel="dashboard">
|
||||
<span class="nav-icon">⊞</span><span class="nav-label">Dashboard</span>
|
||||
</button>
|
||||
<div class="nav-section-label">People</div>
|
||||
<button class="nav-pill" data-panel="kids">
|
||||
<span class="nav-icon">🧒</span><span class="nav-label">Kids</span>
|
||||
</button>
|
||||
<button class="nav-pill" data-panel="messages">
|
||||
<span class="nav-icon">💬</span><span class="nav-label">Messages</span>
|
||||
</button>
|
||||
<div class="nav-section-label">Academics</div>
|
||||
<button class="nav-pill" data-panel="courses">
|
||||
<span class="nav-icon">📚</span><span class="nav-label">Courses</span>
|
||||
</button>
|
||||
<button class="nav-pill" data-panel="planner">
|
||||
<span class="nav-icon">🗓️</span><span class="nav-label">Planner</span>
|
||||
</button>
|
||||
<button class="nav-pill" data-panel="grading">
|
||||
<span class="nav-icon">✅</span><span class="nav-label">Grading</span>
|
||||
</button>
|
||||
<button class="nav-pill" data-panel="gradebook">
|
||||
<span class="nav-icon">📊</span><span class="nav-label">Gradebook</span>
|
||||
</button>
|
||||
<div class="nav-section-label">Tools</div>
|
||||
<button class="nav-pill" data-panel="spelling">
|
||||
<span class="nav-icon">✏️</span><span class="nav-label">Spelling</span>
|
||||
</button>
|
||||
<button class="nav-pill" data-panel="decks">
|
||||
<span class="nav-icon">🗂️</span><span class="nav-label">Flashcards</span>
|
||||
</button>
|
||||
<button class="nav-pill" data-panel="docs">
|
||||
<span class="nav-icon">📝</span><span class="nav-label">Docs</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="admin-sidebar-footer">
|
||||
<button class="nav-pill" data-panel="settings">
|
||||
<span class="nav-icon">⚙️</span><span class="nav-label">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="admin-body">
|
||||
<header class="admin-topbar">
|
||||
<button class="sidebar-toggle" id="sidebar-toggle" aria-label="Toggle menu">☰</button>
|
||||
<h1 class="admin-page-title" id="admin-page-title">Dashboard</h1>
|
||||
<div class="admin-topbar-right">
|
||||
<a href="index.html" class="topbar-btn">← Student view</a>
|
||||
<button class="topbar-btn topbar-lock" id="btn-lock">🔒 Lock</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-main">
|
||||
|
||||
<!-- Dashboard -->
|
||||
<section id="panel-dashboard" class="panel">
|
||||
<div id="dashboard-greeting" class="dash-greeting"></div>
|
||||
<div id="dashboard-kid-cards" class="dash-kid-grid"></div>
|
||||
<div class="dash-bottom-row">
|
||||
<div class="dash-col">
|
||||
<h3 class="dash-col-title">Today's Schedule</h3>
|
||||
<div id="dashboard-schedule" class="dash-schedule-list"></div>
|
||||
</div>
|
||||
<div class="dash-col">
|
||||
<h3 class="dash-col-title">Recent Scores</h3>
|
||||
<div id="dashboard-scores" class="dash-score-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Kids -->
|
||||
<section id="panel-kids" class="panel">
|
||||
<section id="panel-kids" class="panel" hidden>
|
||||
<h1>Kids</h1>
|
||||
<div class="panel-cols">
|
||||
<div class="panel-col">
|
||||
@@ -331,30 +389,37 @@
|
||||
|
||||
<!-- Planner -->
|
||||
<section id="panel-planner" class="panel">
|
||||
<h1>Planner</h1>
|
||||
<div class="inline-form">
|
||||
<label>Kid <select id="planner-student"></select></label>
|
||||
<button id="planner-view-week" class="secondary active-view">Week</button>
|
||||
<button id="planner-view-month" class="secondary">Month</button>
|
||||
<button id="planner-prev" class="secondary">← Prev</button>
|
||||
<span id="planner-week-label" class="week-label"></span>
|
||||
<button id="planner-next" class="secondary">Next →</button>
|
||||
<button id="planner-today" class="secondary">Today</button>
|
||||
<button id="planner-copy" class="secondary">Copy last week</button>
|
||||
<button id="planner-print">🖨 Print</button>
|
||||
<button id="planner-add-btn">+ Add task</button>
|
||||
|
||||
<div class="planner-toolbar">
|
||||
<div class="planner-toolbar-left">
|
||||
<div class="planner-view-toggle">
|
||||
<button id="planner-view-week" class="view-toggle-btn active">Week</button>
|
||||
<button id="planner-view-month" class="view-toggle-btn">Month</button>
|
||||
</div>
|
||||
<div class="planner-nav-group">
|
||||
<button id="planner-prev" class="planner-nav-btn">‹</button>
|
||||
<span id="planner-week-label" class="week-label"></span>
|
||||
<button id="planner-next" class="planner-nav-btn">›</button>
|
||||
</div>
|
||||
<button id="planner-today" class="secondary small">Today</button>
|
||||
</div>
|
||||
<div class="planner-toolbar-right">
|
||||
<select id="planner-student" class="planner-kid-select"></select>
|
||||
<button id="planner-copy" class="secondary small" title="Copy last week's tasks to this week">📋 Copy week</button>
|
||||
<button id="planner-print" class="secondary small">🖨 Print</button>
|
||||
<button id="planner-add-btn">+ Add task</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details id="auto-schedule-details" class="admin-card" style="margin-bottom:1rem">
|
||||
<summary style="cursor:pointer;font-weight:600">⚡ Auto-schedule a course</summary>
|
||||
<div class="inline-form" style="margin-top:.75rem">
|
||||
<label>Course <select id="auto-course"></select></label>
|
||||
<label>Start date <input type="date" id="auto-start-date"></label>
|
||||
<label>Items/day <input type="number" id="auto-items-per-day" value="1" min="1" max="10" style="width:5rem"></label>
|
||||
<button id="auto-schedule-btn">Schedule</button>
|
||||
</div>
|
||||
<p id="auto-schedule-msg" class="hint" style="margin-top:.5rem"></p>
|
||||
</details>
|
||||
<div class="planner-auto-bar">
|
||||
<span class="planner-auto-label">⚡ Auto-schedule</span>
|
||||
<select id="auto-course"></select>
|
||||
from <input type="date" id="auto-start-date" style="width:8rem">
|
||||
<input type="number" id="auto-items-per-day" value="1" min="1" max="10" style="width:3.5rem" title="Items per day">
|
||||
<span style="color:var(--muted);font-size:.8rem">items/day</span>
|
||||
<button id="auto-schedule-btn" class="secondary small">Schedule</button>
|
||||
<span id="auto-schedule-msg" class="hint"></span>
|
||||
</div>
|
||||
|
||||
<div id="planner-grid" class="planner-grid"></div>
|
||||
</section>
|
||||
@@ -617,6 +682,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Docs -->
|
||||
<section id="panel-docs" class="panel" hidden>
|
||||
<h1>Submitted Documents</h1>
|
||||
<div id="admin-docs-list">
|
||||
<p class="hint">Loading…</p>
|
||||
</div>
|
||||
<!-- Full-screen doc viewer -->
|
||||
<div id="admin-doc-viewer" hidden>
|
||||
<div class="docs-editor-toolbar">
|
||||
<button id="btn-admin-doc-back" class="docs-back-btn">← Back to list</button>
|
||||
<span id="admin-doc-viewer-title" class="docs-editor-title-text"></span>
|
||||
<a id="admin-doc-download" class="docs-submit-btn" download>⬇ Download</a>
|
||||
</div>
|
||||
<div id="admin-doc-frame"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings -->
|
||||
<section id="panel-settings" class="panel">
|
||||
<h1>Settings</h1>
|
||||
@@ -683,7 +765,8 @@
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div><!-- /.admin-body -->
|
||||
</div><!-- /#console -->
|
||||
|
||||
<!-- Floating assign popup -->
|
||||
<div id="assign-popup" hidden>
|
||||
@@ -710,6 +793,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="admin.js"></script>
|
||||
<script src="admin.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
+238
-25
@@ -82,22 +82,25 @@ async function unlock() {
|
||||
await loadAppSettings();
|
||||
$('#pin-gate').hidden = true;
|
||||
$('#console').hidden = false;
|
||||
showPanel('kids');
|
||||
showPanel('dashboard');
|
||||
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
||||
loadGradingBadge();
|
||||
loadMessagesBadge();
|
||||
startAdminMsgPoll();
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
async function loadGradingBadge() {
|
||||
try {
|
||||
const { count } = await api('/api/grading-queue/count');
|
||||
const pill = document.querySelector('.nav-pill[data-panel="grading"]');
|
||||
if (!pill) return;
|
||||
const lbl = pill.querySelector('.nav-label');
|
||||
if (count > 0) {
|
||||
pill.textContent = `✅ Grading (${count})`;
|
||||
if (lbl) lbl.textContent = `Grading (${count})`;
|
||||
pill.classList.add('grading-pending');
|
||||
} else {
|
||||
pill.textContent = '✅ Grading';
|
||||
if (lbl) lbl.textContent = 'Grading';
|
||||
pill.classList.remove('grading-pending');
|
||||
}
|
||||
} catch {}
|
||||
@@ -107,25 +110,127 @@ if (parentPin) unlock();
|
||||
|
||||
// ---------- navigation ----------
|
||||
|
||||
document.querySelectorAll('.admin-nav .nav-pill').forEach((pill) =>
|
||||
document.querySelectorAll('.admin-nav .nav-pill, .admin-sidebar-footer .nav-pill').forEach((pill) =>
|
||||
pill.addEventListener('click', () => showPanel(pill.dataset.panel))
|
||||
);
|
||||
document.querySelectorAll('[data-nav]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => showPanel(btn.dataset.nav))
|
||||
);
|
||||
|
||||
const PANEL_TITLES = {
|
||||
dashboard: 'Dashboard', kids: 'Kids', courses: 'Courses', planner: 'Planner',
|
||||
grading: 'Grading', messages: 'Messages', gradebook: 'Gradebook',
|
||||
spelling: 'Spelling', decks: 'Flashcards', docs: 'Documents', settings: 'Settings',
|
||||
'course-detail': 'Course Detail', 'item-editor': 'Item Editor',
|
||||
};
|
||||
|
||||
function showPanel(name) {
|
||||
document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== `panel-${name}`));
|
||||
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name));
|
||||
document.querySelectorAll('.admin-nav .nav-pill, .admin-sidebar-footer .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name));
|
||||
const titleEl = document.getElementById('admin-page-title');
|
||||
if (titleEl) titleEl.textContent = PANEL_TITLES[name] || name;
|
||||
const loaders = {
|
||||
kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
|
||||
dashboard: loadDashboard, kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
|
||||
grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
|
||||
settings: loadSettings,
|
||||
docs: loadAdminDocs, settings: loadSettings,
|
||||
};
|
||||
if (loaders[name]) loaders[name]();
|
||||
if (name === 'course-detail') openCourseDetail(currentCourseId);
|
||||
}
|
||||
|
||||
// sidebar toggle + lock button
|
||||
document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
|
||||
document.querySelector('.admin-sidebar')?.classList.toggle('open');
|
||||
});
|
||||
document.getElementById('btn-lock')?.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('admin_pin');
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Dashboard
|
||||
// ============================================================
|
||||
|
||||
async function loadDashboard() {
|
||||
const greetEl = document.getElementById('dashboard-greeting');
|
||||
const kidGrid = document.getElementById('dashboard-kid-cards');
|
||||
const schedEl = document.getElementById('dashboard-schedule');
|
||||
const scoresEl = document.getElementById('dashboard-scores');
|
||||
if (!greetEl) return;
|
||||
|
||||
const hr = new Date().getHours();
|
||||
const greet = hr < 12 ? 'Good morning' : hr < 17 ? 'Good afternoon' : 'Good evening';
|
||||
const dateStr = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
greetEl.innerHTML = `<h1>${greet}! 👋</h1><p>${dateStr}</p>`;
|
||||
|
||||
try {
|
||||
const [students, scheduleData, recentScores] = await Promise.all([
|
||||
api('/api/students'),
|
||||
api(`/api/admin/schedule?date=${new Date().toISOString().slice(0,10)}`).catch(() => ({ rows: [] })),
|
||||
api('/api/admin/recent-scores').catch(() => ({ scores: [] })),
|
||||
]);
|
||||
|
||||
const THEME_HEX = { blue:'#4f86f7', green:'#2e9e5b', purple:'#7c5cbf', orange:'#e8802a', pink:'#d45d8a', red:'#e84040', teal:'#20a8a0', yellow:'#c8960c', indigo:'#5b6abf' };
|
||||
const rows = scheduleData.rows || scheduleData || [];
|
||||
|
||||
kidGrid.innerHTML = '';
|
||||
for (const s of students) {
|
||||
const kidRows = rows.filter(r => r.student_id === s.id);
|
||||
const total = kidRows.length;
|
||||
const done = kidRows.filter(r => r.done).length;
|
||||
const pct = total ? Math.round(done / total * 100) : 0;
|
||||
const accent = THEME_HEX[s.theme?.replace('theme-','')] || '#4f7ef8';
|
||||
kidGrid.innerHTML += `
|
||||
<div class="dash-kid-card" style="--kid-accent:${accent}">
|
||||
<div class="dash-kid-top">
|
||||
<span class="dash-kid-avatar">${s.emoji || '🧒'}</span>
|
||||
<div>
|
||||
<div class="dash-kid-name">${s.name}</div>
|
||||
<div class="dash-kid-streak">🔥 ${s.streak_count || 0}-day streak</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-kid-stats">
|
||||
<div class="dash-stat-box">
|
||||
<div class="dash-stat-num">${done}</div>
|
||||
<div class="dash-stat-lbl">Done today</div>
|
||||
</div>
|
||||
<div class="dash-stat-box">
|
||||
<div class="dash-stat-num">${total}</div>
|
||||
<div class="dash-stat-lbl">Scheduled</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-progress-bar">
|
||||
<div class="dash-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
schedEl.innerHTML = rows.length ? rows.slice(0, 12).map(r => {
|
||||
const kidName = students.find(s => s.id === r.student_id)?.name || '';
|
||||
return `<div class="dash-schedule-item${r.done ? ' done' : ''}">
|
||||
<span class="sch-dot"></span>
|
||||
<span>${r.title}</span>
|
||||
<span class="dash-schedule-who">${kidName}</span>
|
||||
</div>`;
|
||||
}).join('') : '<p style="color:var(--muted);font-size:.82rem">No schedule for today.</p>';
|
||||
|
||||
const scores = recentScores.scores || recentScores || [];
|
||||
scoresEl.innerHTML = scores.length ? scores.slice(0, 8).map(sc => {
|
||||
const pct = sc.points_possible > 0 ? Math.round(sc.score / sc.points_possible * 100) : null;
|
||||
const cls = pct === null ? '' : pct >= 80 ? '' : pct >= 60 ? ' mid' : ' low';
|
||||
const kidName = students.find(s => s.id === sc.student_id)?.name || '';
|
||||
return `<div class="dash-score-item">
|
||||
<span>${sc.title}</span>
|
||||
<span class="dash-score-who" style="font-size:.7rem;color:var(--muted);margin-left:.25rem">${kidName}</span>
|
||||
<span class="dash-score-pct${cls}">${pct !== null ? pct+'%' : sc.score+' pts'}</span>
|
||||
</div>`;
|
||||
}).join('') : '<p style="color:var(--muted);font-size:.82rem">No scores yet.</p>';
|
||||
|
||||
} catch (e) {
|
||||
kidGrid.innerHTML = '<p style="color:var(--muted)">Could not load dashboard data.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Kids
|
||||
// ============================================================
|
||||
@@ -1310,8 +1415,8 @@ $('#planner-student').addEventListener('change', () => {
|
||||
});
|
||||
$('#planner-view-week').addEventListener('click', () => {
|
||||
plannerMode = 'week';
|
||||
$('#planner-view-week').classList.add('active-view');
|
||||
$('#planner-view-month').classList.remove('active-view');
|
||||
$('#planner-view-week').classList.add('active');
|
||||
$('#planner-view-month').classList.remove('active');
|
||||
$('#planner-copy').hidden = false;
|
||||
renderPlanner();
|
||||
});
|
||||
@@ -1321,8 +1426,8 @@ $('#planner-view-month').addEventListener('click', () => {
|
||||
const ref = new Date(plannerWeekStart + 'T00:00:00');
|
||||
plannerMonthRef = `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
plannerWeekStart = firstWeekStartOfMonth(plannerMonthRef);
|
||||
$('#planner-view-month').classList.add('active-view');
|
||||
$('#planner-view-week').classList.remove('active-view');
|
||||
$('#planner-view-month').classList.add('active');
|
||||
$('#planner-view-week').classList.remove('active');
|
||||
$('#planner-copy').hidden = true;
|
||||
renderPlanner();
|
||||
});
|
||||
@@ -1455,26 +1560,53 @@ function renderDayCell(day, tasks, compact = false, spellingTests = [], today =
|
||||
const dayTasks = tasks.filter((t) => t.date === day.date);
|
||||
const dayTests = spellingTests.filter((t) => t.date === day.date);
|
||||
const isToday = day.date === today;
|
||||
const isPast = day.date < today;
|
||||
const doneCount = dayTasks.filter(t => t.done).length;
|
||||
const total = dayTasks.length + dayTests.length;
|
||||
|
||||
const testBadges = dayTests.map((t) => {
|
||||
const pct = Math.round((t.score / t.total) * 100);
|
||||
return `<div class="planner-task planner-test-badge" title="${esc(t.list)}">⭐ Spelling ${t.score}/${t.total} (${pct}%)</div>`;
|
||||
}).join('');
|
||||
const taskHtml = dayTasks.map((t) => {
|
||||
const colorStyle = t.courseColor ? ` style="border-left:3px solid ${esc(t.courseColor)}"` : '';
|
||||
return `<div class="planner-task${t.done ? ' done' : ''}" draggable="true" data-task-id="${t.id}"${colorStyle}>
|
||||
<button class="planner-done-btn${t.done ? ' is-done' : ''}" data-done-schedule="${t.id}" data-is-done="${t.done ? '1' : '0'}" title="${t.done ? 'Mark not done' : 'Mark done'}">✓</button>
|
||||
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
|
||||
<button class="danger tiny" data-del-schedule="${t.id}">✕</button>
|
||||
const cls = pct >= 80 ? 'good' : pct >= 60 ? 'mid' : 'bad';
|
||||
return `<div class="planner-task planner-test-badge">
|
||||
<span class="task-icon">✏️</span>
|
||||
<span class="task-title">Spelling test</span>
|
||||
<span class="task-score ${cls}">${pct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const taskHtml = dayTasks.map((t) => {
|
||||
const border = t.courseColor ? `border-left-color:${esc(t.courseColor)}` : '';
|
||||
const courseName = t.courseName ? `<span class="task-course">${esc(t.courseName)}</span>` : '';
|
||||
return `<div class="planner-task${t.done ? ' done' : ''}" draggable="true" data-task-id="${t.id}" style="${border}">
|
||||
<button class="planner-done-btn${t.done ? ' is-done' : ''}" data-done-schedule="${t.id}" data-is-done="${t.done ? '1' : '0'}" title="${t.done ? 'Undo' : 'Mark done'}">✓</button>
|
||||
<div class="task-body">
|
||||
<span class="task-title">${t.itemId ? TYPE_ICON[t.type] || '' : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
|
||||
${courseName}
|
||||
</div>
|
||||
<button class="planner-del-btn" data-del-schedule="${t.id}" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const progress = total > 0
|
||||
? `<span class="day-progress${doneCount === total ? ' all-done' : ''}">${doneCount}/${total}</span>`
|
||||
: '';
|
||||
const emptyState = !taskHtml && !testBadges
|
||||
? `<button class="planner-empty-add" data-add-task="${day.date}">+ Add task</button>`
|
||||
: '';
|
||||
|
||||
const fmtDate = (d) => { const dt = new Date(d + 'T00:00:00'); return dt.toLocaleString('default', { month: 'short', day: 'numeric' }); };
|
||||
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}${isToday ? ' today' : ''}" data-drop-date="${day.date}">
|
||||
<h3>${compact ? day.name.slice(0,3) : day.name}<small>${fmtDate(day.date)}</small></h3>
|
||||
<div class="planner-tasks" data-drop-date="${day.date}">
|
||||
${testBadges}
|
||||
${taskHtml || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
|
||||
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}${isToday ? ' is-today' : ''}${isPast && !compact ? ' is-past' : ''}" data-drop-date="${day.date}">
|
||||
<div class="planner-day-header">
|
||||
<div class="day-label">
|
||||
<span class="day-name">${compact ? day.name.slice(0,3) : day.name}</span>
|
||||
<span class="day-date">${fmtDate(day.date)}</span>
|
||||
</div>
|
||||
${progress}
|
||||
</div>
|
||||
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
|
||||
<div class="planner-tasks" data-drop-date="${day.date}">
|
||||
${testBadges}${taskHtml}${emptyState}
|
||||
</div>
|
||||
${taskHtml || testBadges ? `<button class="planner-add-more" data-add-task="${day.date}">+ Add</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -3158,3 +3290,84 @@ $('#app-settings-form').addEventListener('submit', async (e) => {
|
||||
msg('Settings saved.');
|
||||
});
|
||||
|
||||
|
||||
// ── Admin: Submitted Documents ────────────────────────────────────────────────
|
||||
|
||||
let adminDocsEditorInstance = null;
|
||||
|
||||
async function loadAdminDocs() {
|
||||
const el = $('#admin-docs-list');
|
||||
let docs;
|
||||
try {
|
||||
({ docs } = await api('/api/admin/docs'));
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p class="hint" style="color:red">Error loading documents: ${esc(err.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
if (!docs.length) {
|
||||
el.innerHTML = '<p class="hint">No submitted documents yet.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = docs.map((d) => `
|
||||
<div class="doc-card${!d.parent_read ? ' inbox-new' : ''}" style="cursor:pointer"
|
||||
data-id="${d.id}" data-key="${esc(d.doc_key)}" data-filename="${esc(d.filename)}" data-title="${esc(d.title)}">
|
||||
<div class="doc-card-icon">📄</div>
|
||||
<div class="doc-card-info">
|
||||
<div class="doc-card-title">${esc(d.title)}${!d.parent_read ? ' <span class="feedback-badge">New</span>' : ''}</div>
|
||||
<div class="doc-card-meta">${esc(d.student_name)} · Submitted ${new Date(d.submitted_at + 'Z').toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
el.querySelectorAll('.doc-card').forEach((card) => {
|
||||
card.addEventListener('click', () => openAdminDocViewer(
|
||||
+card.dataset.id, card.dataset.key, card.dataset.filename, card.dataset.title
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
async function openAdminDocViewer(id, key, filename, title) {
|
||||
await api(`/api/admin/docs/${id}/read`, { method: 'PUT' });
|
||||
$('#admin-docs-list').hidden = true;
|
||||
$('#admin-doc-viewer').hidden = false;
|
||||
$('#admin-doc-viewer-title').textContent = title;
|
||||
$('#admin-doc-download').href = `/api/docs/file/${filename}`;
|
||||
$('#admin-doc-download').download = title + '.docx';
|
||||
|
||||
const frame = $('#admin-doc-frame');
|
||||
frame.innerHTML = '';
|
||||
if (adminDocsEditorInstance) {
|
||||
try { adminDocsEditorInstance.destroyEditor(); } catch { /* ignore */ }
|
||||
adminDocsEditorInstance = null;
|
||||
}
|
||||
|
||||
const { onlyofficeUrl, config } = await api(`/api/admin/docs/bykey/${key}/editor-config`).catch(() => null) || {};
|
||||
if (!onlyofficeUrl || !config) {
|
||||
frame.innerHTML = `<div class="docs-error">Could not load document config.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
adminDocsEditorInstance = new window.DocsAPI.DocEditor('admin-doc-frame', config);
|
||||
}
|
||||
if (window.DocsAPI) {
|
||||
initEditor();
|
||||
} else {
|
||||
const s = document.createElement('script');
|
||||
s.src = `${onlyofficeUrl}/web-apps/apps/api/documents/api.js`;
|
||||
s.onload = initEditor;
|
||||
s.onerror = () => { frame.innerHTML = `<div class="docs-error">Could not reach OnlyOffice server.</div>`; };
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('btn-admin-doc-back')?.addEventListener('click', () => {
|
||||
if (adminDocsEditorInstance) {
|
||||
try { adminDocsEditorInstance.destroyEditor(); } catch { /* ignore */ }
|
||||
adminDocsEditorInstance = null;
|
||||
}
|
||||
$('#admin-doc-frame').innerHTML = '';
|
||||
$('#admin-doc-viewer').hidden = true;
|
||||
$('#admin-docs-list').hidden = false;
|
||||
loadAdminDocs();
|
||||
});
|
||||
+160
-1
@@ -313,7 +313,7 @@ document.querySelectorAll('.nav-pill').forEach((pill) =>
|
||||
pill.addEventListener('click', () => switchTab(pill.dataset.tab))
|
||||
);
|
||||
|
||||
const TAB_ORDER = ['today', 'courses', 'spelling', 'inbox'];
|
||||
const TAB_ORDER = ['today', 'courses', 'spelling', 'inbox', 'docs'];
|
||||
let currentTab = 'today';
|
||||
|
||||
function switchTab(name) {
|
||||
@@ -331,6 +331,7 @@ function switchTab(name) {
|
||||
}
|
||||
});
|
||||
if (name === 'inbox') loadInbox();
|
||||
if (name === 'docs') loadDocs();
|
||||
}
|
||||
|
||||
function inboxSeenKey(studentId) { return `inbox_seen_${studentId}`; }
|
||||
@@ -2518,6 +2519,164 @@ $('#btn-test').addEventListener('click', () => {
|
||||
startTest();
|
||||
});
|
||||
|
||||
$('#btn-freetype').addEventListener('click', () => {
|
||||
backTarget = () => openKid(currentStudent.id, 'spelling');
|
||||
$('#freetype-feedback').innerHTML = '';
|
||||
$('#freetype-input').value = '';
|
||||
show('freetype');
|
||||
$('#freetype-input').focus();
|
||||
});
|
||||
|
||||
$('#freetype-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const input = $('#freetype-input');
|
||||
const typed = input.value.trim();
|
||||
if (!typed) return;
|
||||
const fb = $('#freetype-feedback');
|
||||
try {
|
||||
const r = await api(`/api/spellcheck?word=${encodeURIComponent(typed)}`);
|
||||
if (r.correct) {
|
||||
fb.innerHTML = `<div class="banner good">✅ "${esc(r.word)}" is spelled correctly!</div>`;
|
||||
} else {
|
||||
const tips = r.suggestions.length
|
||||
? ` Did you mean: ${r.suggestions.map((x) => `<strong>${esc(x)}</strong>`).join(', ')}?`
|
||||
: '';
|
||||
fb.innerHTML = `<div class="banner bad">❌ "${esc(r.word)}" is not spelled correctly.${tips}</div>`;
|
||||
alert(`"${r.word}" is spelled wrong. Try again!`);
|
||||
}
|
||||
} catch (err) {
|
||||
fb.innerHTML = `<div class="banner bad">${esc(err.message)}</div>`;
|
||||
}
|
||||
input.select();
|
||||
});
|
||||
|
||||
// ---------- docs (OnlyOffice) ----------
|
||||
|
||||
let docsEditorInstance = null;
|
||||
let currentDocId = null;
|
||||
|
||||
async function loadDocs() {
|
||||
const sid = currentStudent?.id;
|
||||
if (!sid) return;
|
||||
const { docs } = await api(`/api/docs?studentId=${sid}`);
|
||||
const list = $('#docs-list');
|
||||
const empty = $('#docs-empty');
|
||||
if (!docs.length) {
|
||||
list.innerHTML = '';
|
||||
empty.hidden = false;
|
||||
return;
|
||||
}
|
||||
empty.hidden = true;
|
||||
list.innerHTML = docs.map((d) => `
|
||||
<div class="doc-card${d.submitted ? ' doc-submitted' : ''}" data-id="${d.id}" data-key="${esc(d.doc_key)}" data-title="${esc(d.title)}">
|
||||
<div class="doc-card-icon">📄</div>
|
||||
<div class="doc-card-info">
|
||||
<div class="doc-card-title">${esc(d.title)}</div>
|
||||
<div class="doc-card-meta">${d.submitted ? '✓ Submitted' : 'Draft'} · ${new Date(d.updated_at + 'Z').toLocaleDateString()}</div>
|
||||
</div>
|
||||
<button class="doc-delete-btn" data-id="${d.id}" aria-label="Delete">🗑</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll('.doc-card').forEach((card) => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.doc-delete-btn')) return;
|
||||
openDocEditor(+card.dataset.id, card.dataset.key, card.dataset.title);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.doc-delete-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Delete this document?')) return;
|
||||
await api(`/api/docs/${btn.dataset.id}`, { method: 'DELETE' });
|
||||
loadDocs();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openDocEditor(id, key, title) {
|
||||
currentDocId = id;
|
||||
$('#docs-list-view').hidden = true;
|
||||
$('#docs-editor-view').hidden = false;
|
||||
$('#docs-editor-title').textContent = title;
|
||||
$('#btn-docs-submit').disabled = false;
|
||||
$('#btn-docs-submit').textContent = 'Submit to Parent ✓';
|
||||
|
||||
document.body.classList.add('docs-editor-open');
|
||||
const frame = $('#docs-editor-frame');
|
||||
frame.innerHTML = '';
|
||||
if (docsEditorInstance) {
|
||||
try { docsEditorInstance.destroyEditor(); } catch { /* ignore */ }
|
||||
docsEditorInstance = null;
|
||||
}
|
||||
|
||||
const { onlyofficeUrl, config } = await api(`/api/docs/${id}/editor-config`).catch(() => null) || {};
|
||||
if (!onlyofficeUrl || !config) {
|
||||
frame.innerHTML = `<div class="docs-error">Could not load document config from server.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const apiScript = `${onlyofficeUrl}/web-apps/apps/api/documents/api.js`;
|
||||
|
||||
function initEditor() {
|
||||
docsEditorInstance = new window.DocsAPI.DocEditor('docs-editor-frame', config);
|
||||
}
|
||||
|
||||
if (window.DocsAPI) {
|
||||
initEditor();
|
||||
} else {
|
||||
const s = document.createElement('script');
|
||||
s.src = apiScript;
|
||||
s.onload = initEditor;
|
||||
s.onerror = () => {
|
||||
frame.innerHTML = `<div class="docs-error">Could not load OnlyOffice editor from <code>${onlyofficeUrl}</code>. Make sure the server at <strong>${onlyofficeUrl}</strong> is reachable from this device.</div>`;
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
$('#btn-new-doc').addEventListener('click', () => {
|
||||
$('#btn-new-doc').hidden = true;
|
||||
$('#docs-new-form').hidden = false;
|
||||
$('#docs-new-title').value = '';
|
||||
$('#docs-new-title').focus();
|
||||
});
|
||||
|
||||
$('#btn-docs-new-cancel').addEventListener('click', () => {
|
||||
$('#docs-new-form').hidden = true;
|
||||
$('#btn-new-doc').hidden = false;
|
||||
});
|
||||
|
||||
$('#docs-new-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const title = $('#docs-new-title').value.trim() || 'Untitled Document';
|
||||
$('#docs-new-form').hidden = true;
|
||||
$('#btn-new-doc').hidden = false;
|
||||
const { id, key } = await api('/api/docs', { method: 'POST', body: { studentId: currentStudent.id, title } });
|
||||
await loadDocs();
|
||||
openDocEditor(id, key, title);
|
||||
});
|
||||
|
||||
$('#btn-docs-back').addEventListener('click', () => {
|
||||
document.body.classList.remove('docs-editor-open');
|
||||
if (docsEditorInstance) {
|
||||
try { docsEditorInstance.destroyEditor(); } catch { /* ignore */ }
|
||||
docsEditorInstance = null;
|
||||
}
|
||||
$('#docs-editor-frame').innerHTML = '';
|
||||
$('#docs-editor-view').hidden = true;
|
||||
$('#docs-list-view').hidden = false;
|
||||
loadDocs();
|
||||
});
|
||||
|
||||
$('#btn-docs-submit').addEventListener('click', async () => {
|
||||
if (!currentDocId) return;
|
||||
if (!confirm('Submit this document to your parent?')) return;
|
||||
await api(`/api/docs/${currentDocId}/submit`, { method: 'POST' });
|
||||
$('#btn-docs-submit').textContent = '✓ Submitted!';
|
||||
$('#btn-docs-submit').disabled = true;
|
||||
});
|
||||
|
||||
// ---------- boot ----------
|
||||
|
||||
loadHome();
|
||||
+56
-13
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Homeschool</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="styles.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -44,15 +44,17 @@
|
||||
|
||||
<!-- 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>
|
||||
<div class="kid-topbar-actions">
|
||||
<button id="icon-picker-btn" class="theme-picker-btn" aria-label="Change my animal">🐾</button>
|
||||
<button id="theme-picker-btn" class="theme-picker-btn" aria-label="Pick my color">🎨</button>
|
||||
<button id="bg-picker-btn" class="theme-picker-btn" aria-label="Pick my background">🖼️</button>
|
||||
<div class="kid-hero">
|
||||
<div class="kid-topbar">
|
||||
<button class="back-btn" data-nav="home">← Switch</button>
|
||||
<div class="kid-topbar-actions">
|
||||
<button id="icon-picker-btn" class="theme-picker-btn" aria-label="Change my animal">🐾</button>
|
||||
<button id="theme-picker-btn" class="theme-picker-btn" aria-label="Pick my color">🎨</button>
|
||||
<button id="bg-picker-btn" class="theme-picker-btn" aria-label="Pick my background">🖼️</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 id="kid-greeting"></h1>
|
||||
</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>
|
||||
@@ -75,10 +77,11 @@
|
||||
</div>
|
||||
|
||||
<div class="kid-nav">
|
||||
<button class="nav-pill active" data-tab="today">📋 Today</button>
|
||||
<button class="nav-pill" data-tab="courses">📚 My Courses</button>
|
||||
<button class="nav-pill" data-tab="spelling">✏️ Spelling</button>
|
||||
<button class="nav-pill" data-tab="inbox">📬 Inbox<span id="inbox-badge" class="feedback-badge" hidden></span></button>
|
||||
<button class="nav-pill active" data-tab="today"><span class="np-icon">📋</span><span class="np-label">Today</span></button>
|
||||
<button class="nav-pill" data-tab="courses"><span class="np-icon">📚</span><span class="np-label">Courses</span></button>
|
||||
<button class="nav-pill" data-tab="spelling"><span class="np-icon">✏️</span><span class="np-label">Spelling</span></button>
|
||||
<button class="nav-pill" data-tab="inbox"><span class="np-icon">📬</span><span class="np-label">Inbox<span id="inbox-badge" class="feedback-badge" hidden></span></span></button>
|
||||
<button class="nav-pill" data-tab="docs"><span class="np-icon">📝</span><span class="np-label">Docs</span></button>
|
||||
</div>
|
||||
|
||||
<div id="tab-today" class="tab-panel">
|
||||
@@ -96,6 +99,7 @@
|
||||
<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-freetype" class="big-btn print-list">🔍 Check any word</button>
|
||||
<button id="btn-print-list" class="big-btn print-list">🖨 Print my words</button>
|
||||
</div>
|
||||
<div id="kid-test-history"></div>
|
||||
@@ -124,8 +128,33 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-docs" class="tab-panel" hidden>
|
||||
<div id="docs-list-view">
|
||||
<div class="docs-toolbar">
|
||||
<button id="btn-new-doc" class="docs-new-btn">+ New Document</button>
|
||||
</div>
|
||||
<form id="docs-new-form" class="docs-new-form" hidden>
|
||||
<input id="docs-new-title" class="docs-new-input" type="text" placeholder="Document title…" maxlength="120" autocomplete="off">
|
||||
<button type="submit" class="docs-new-btn">Create</button>
|
||||
<button type="button" id="btn-docs-new-cancel" class="docs-cancel-btn">Cancel</button>
|
||||
</form>
|
||||
<div id="docs-list"></div>
|
||||
<p id="docs-empty" class="hint" hidden>No documents yet — tap + New Document to start writing!</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Docs full-screen editor overlay (outside section so fixed positioning is never trapped) -->
|
||||
<div id="docs-editor-view" hidden>
|
||||
<div class="docs-editor-toolbar">
|
||||
<button id="btn-docs-back" class="docs-back-btn">← Back</button>
|
||||
<span id="docs-editor-title" class="docs-editor-title-text"></span>
|
||||
<button id="btn-docs-submit" class="docs-submit-btn">Submit to Parent ✓</button>
|
||||
</div>
|
||||
<div id="docs-editor-frame"></div>
|
||||
</div>
|
||||
|
||||
<!-- All-done celebration overlay -->
|
||||
<div id="celebration-overlay" hidden>
|
||||
<div id="celebration-content">
|
||||
@@ -245,6 +274,20 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Free-type spell check -->
|
||||
<section id="view-freetype" class="view" hidden>
|
||||
<button class="back-btn" data-back="freetype">← Back</button>
|
||||
<div class="word-card">
|
||||
<p class="prompt">Type any word and I'll tell you if it's spelled right:</p>
|
||||
<div id="freetype-feedback" class="feedback"></div>
|
||||
<form id="freetype-form" autocomplete="off">
|
||||
<input id="freetype-input" class="spell-input" type="text" autocomplete="off"
|
||||
autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Type a word" enterkeyhint="go">
|
||||
<p class="hint">Press Enter to check</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Practice (spelling) -->
|
||||
<section id="view-practice" class="view" hidden>
|
||||
<button class="back-btn" data-back="practice">← Back</button>
|
||||
@@ -338,6 +381,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1654
-922
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* Demo seed script — wipes all existing data and inserts realistic
|
||||
* homeschool demo data for two kids (Emma & Liam).
|
||||
*
|
||||
* Run: node seed.js
|
||||
*/
|
||||
|
||||
import { db } from './db.js';
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────── */
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const daysAgo = n => {
|
||||
const d = new Date(); d.setDate(d.getDate() - n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
const daysAhead = n => {
|
||||
const d = new Date(); d.setDate(d.getDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
const tsAgo = (days, hours = 10) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
d.setHours(hours, 0, 0, 0);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
/* ── wipe ─────────────────────────────────────────────────── */
|
||||
db.exec(`
|
||||
DELETE FROM test_answers;
|
||||
DELETE FROM tests;
|
||||
DELETE FROM submission_history;
|
||||
DELETE FROM submissions;
|
||||
DELETE FROM schedule;
|
||||
DELETE FROM progress;
|
||||
DELETE FROM attempts;
|
||||
DELETE FROM assignments;
|
||||
DELETE FROM card_progress;
|
||||
DELETE FROM cards;
|
||||
DELETE FROM decks;
|
||||
DELETE FROM words;
|
||||
DELETE FROM lists;
|
||||
DELETE FROM quiz_questions;
|
||||
DELETE FROM items;
|
||||
DELETE FROM units;
|
||||
DELETE FROM enrollments;
|
||||
DELETE FROM courses;
|
||||
DELETE FROM students;
|
||||
DELETE FROM messages;
|
||||
`);
|
||||
console.log('Wiped all tables.');
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
STUDENTS
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertStudent = db.prepare(`
|
||||
INSERT INTO students (id, name, emoji, created_at, theme, streak_count, best_streak, streak_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertStudent.run(1, 'Emma', '🦊', tsAgo(60), 'theme-purple', 5, 12, today());
|
||||
insertStudent.run(2, 'Liam', '🐻', tsAgo(45), 'theme-teal', 3, 8, today());
|
||||
console.log('Students created.');
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
COURSES
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertCourse = db.prepare(`
|
||||
INSERT INTO courses (id, name, subject, color, archived)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
`);
|
||||
|
||||
insertCourse.run(1, 'Language Arts', 'English', '#7c3aed');
|
||||
insertCourse.run(2, 'Mathematics', 'Math', '#0d9488');
|
||||
insertCourse.run(3, 'Earth Science', 'Science', '#16a34a');
|
||||
insertCourse.run(4, 'American History', 'History', '#ea580c');
|
||||
insertCourse.run(5, 'Bible', 'Bible', '#d97706');
|
||||
console.log('Courses created.');
|
||||
|
||||
/* ── enrollments ──────────────────────────────────────────── */
|
||||
const enroll = db.prepare('INSERT INTO enrollments (student_id, course_id) VALUES (?, ?)');
|
||||
// Emma takes all 5; Liam takes 4 (no Bible)
|
||||
[1,2,3,4,5].forEach(c => enroll.run(1, c));
|
||||
[1,2,3,4].forEach(c => enroll.run(2, c));
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
UNITS + ITEMS
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertUnit = db.prepare(`INSERT INTO units (id, course_id, name, sort) VALUES (?, ?, ?, ?)`);
|
||||
const insertItem = db.prepare(`
|
||||
INSERT INTO items (id, unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, evidence_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertQ = db.prepare(`
|
||||
INSERT INTO quiz_questions (id, item_id, type, prompt, choices, correct_answer, points, sort)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
let unitId = 1, itemId = 1, qId = 1;
|
||||
|
||||
/* ─── Language Arts ────────────────────────────────────────
|
||||
Unit 1: Grammar Foundations
|
||||
Unit 2: Creative Writing */
|
||||
|
||||
insertUnit.run(unitId, 1, 'Grammar Foundations', 1);
|
||||
const laUnit1 = unitId++;
|
||||
|
||||
// Lesson: Parts of Speech
|
||||
insertItem.run(itemId, laUnit1, 'lesson', 'Parts of Speech', `
|
||||
<h2>What are Parts of Speech?</h2>
|
||||
<p>Every word in English belongs to a <strong>part of speech</strong> — a category that describes how the word works in a sentence.</p>
|
||||
<h3>The 8 Parts of Speech</h3>
|
||||
<ul>
|
||||
<li><strong>Noun</strong> — names a person, place, thing, or idea. <em>Example: dog, school, happiness</em></li>
|
||||
<li><strong>Pronoun</strong> — replaces a noun. <em>Example: he, she, they, it</em></li>
|
||||
<li><strong>Verb</strong> — shows action or state of being. <em>Example: run, is, think</em></li>
|
||||
<li><strong>Adjective</strong> — describes a noun. <em>Example: blue, tall, three</em></li>
|
||||
<li><strong>Adverb</strong> — describes a verb, adjective, or another adverb. <em>Example: quickly, very, well</em></li>
|
||||
<li><strong>Preposition</strong> — shows relationship. <em>Example: in, on, under, beside</em></li>
|
||||
<li><strong>Conjunction</strong> — joins words or phrases. <em>Example: and, but, or, because</em></li>
|
||||
<li><strong>Interjection</strong> — expresses emotion. <em>Example: Wow! Oh! Ouch!</em></li>
|
||||
</ul>
|
||||
<p>Learning parts of speech helps you understand how sentences are built and makes you a stronger reader and writer.</p>
|
||||
`, 0, null, 1, daysAgo(14), 0, 'none');
|
||||
const lessonPos = itemId++;
|
||||
|
||||
// Quiz: Parts of Speech
|
||||
insertItem.run(itemId, laUnit1, 'quiz', 'Parts of Speech Quiz', '', 20, null, 2, daysAgo(10), 1, 'none');
|
||||
const quizPos = itemId++;
|
||||
insertQ.run(qId++, quizPos, 'mc', 'Which word is a NOUN?', JSON.stringify(['quickly','beautiful','mountain','runs']), 'mountain', 5, 1);
|
||||
insertQ.run(qId++, quizPos, 'mc', 'Which word is a VERB?', JSON.stringify(['slowly','purple','jump','the']), 'jump', 5, 2);
|
||||
insertQ.run(qId++, quizPos, 'mc', 'Which word is an ADJECTIVE?', JSON.stringify(['gracefully','enormous','laugh','because']), 'enormous', 5, 3);
|
||||
insertQ.run(qId++, quizPos, 'sa', 'Write a sentence using a preposition.', '[]', '', 5, 4);
|
||||
|
||||
// Lesson: Sentence Structure
|
||||
insertItem.run(itemId, laUnit1, 'lesson', 'Sentence Structure', `
|
||||
<h2>Building Strong Sentences</h2>
|
||||
<p>Every complete sentence needs two things: a <strong>subject</strong> (who or what the sentence is about) and a <strong>predicate</strong> (what the subject does or is).</p>
|
||||
<h3>Simple Sentences</h3>
|
||||
<p>A simple sentence has one independent clause: <em>The dog barked.</em></p>
|
||||
<h3>Compound Sentences</h3>
|
||||
<p>Two independent clauses joined by a conjunction: <em>The dog barked, and the cat hid.</em></p>
|
||||
<h3>Complex Sentences</h3>
|
||||
<p>One independent clause + one dependent clause: <em>Because it was raining, we stayed inside.</em></p>
|
||||
<p>Using a variety of sentence types makes your writing more interesting to read!</p>
|
||||
`, 0, null, 3, daysAgo(7), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertUnit.run(unitId, 1, 'Creative Writing', 2);
|
||||
const laUnit2 = unitId++;
|
||||
|
||||
insertItem.run(itemId, laUnit2, 'lesson', 'The Writing Process', `
|
||||
<h2>Five Steps to Great Writing</h2>
|
||||
<ol>
|
||||
<li><strong>Prewrite</strong> — Brainstorm ideas. Use a web, list, or outline.</li>
|
||||
<li><strong>Draft</strong> — Write your ideas without worrying about perfection.</li>
|
||||
<li><strong>Revise</strong> — Improve your ideas. Add details, rearrange, clarify.</li>
|
||||
<li><strong>Edit</strong> — Fix spelling, grammar, and punctuation.</li>
|
||||
<li><strong>Publish</strong> — Share your finished work!</li>
|
||||
</ol>
|
||||
<p>Good writers don't write perfectly the first time — they revise and improve!</p>
|
||||
`, 0, null, 1, daysAgo(4), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertItem.run(itemId, laUnit2, 'assignment', 'Descriptive Paragraph', `
|
||||
Write a descriptive paragraph (at least 5 sentences) about your favorite place.
|
||||
Use vivid adjectives and at least two different sentence types (simple, compound, or complex).
|
||||
|
||||
Remember to:
|
||||
- Start with a topic sentence
|
||||
- Include sensory details (what you see, hear, smell, feel)
|
||||
- End with a concluding sentence
|
||||
`, 25, null, 2, daysAhead(3), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
/* ─── Mathematics ──────────────────────────────────────────
|
||||
Unit 1: Fractions
|
||||
Unit 2: Geometry */
|
||||
|
||||
insertUnit.run(unitId, 2, 'Fractions & Decimals', 1);
|
||||
const mathUnit1 = unitId++;
|
||||
|
||||
insertItem.run(itemId, mathUnit1, 'lesson', 'Understanding Fractions', `
|
||||
<h2>What Is a Fraction?</h2>
|
||||
<p>A fraction represents a <strong>part of a whole</strong>. It has two parts:</p>
|
||||
<ul>
|
||||
<li><strong>Numerator</strong> — the top number (how many parts you have)</li>
|
||||
<li><strong>Denominator</strong> — the bottom number (how many equal parts the whole is divided into)</li>
|
||||
</ul>
|
||||
<h3>Equivalent Fractions</h3>
|
||||
<p>Fractions that name the same amount are called <strong>equivalent fractions</strong>.</p>
|
||||
<p>Example: 1/2 = 2/4 = 4/8</p>
|
||||
<h3>Simplifying Fractions</h3>
|
||||
<p>Divide the numerator and denominator by their <strong>greatest common factor (GCF)</strong>.</p>
|
||||
<p>Example: 8/12 → GCF is 4 → 2/3</p>
|
||||
`, 0, null, 1, daysAgo(21), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertItem.run(itemId, mathUnit1, 'quiz', 'Fractions Quiz', '', 30, null, 2, daysAgo(14), 1, 'none');
|
||||
const quizFrac = itemId++;
|
||||
insertQ.run(qId++, quizFrac, 'mc', 'What is the simplified form of 6/9?', JSON.stringify(['1/3','2/3','3/4','6/9']), '2/3', 10, 1);
|
||||
insertQ.run(qId++, quizFrac, 'mc', 'Which fraction is equivalent to 1/2?', JSON.stringify(['2/3','3/4','4/8','5/9']), '4/8', 10, 2);
|
||||
insertQ.run(qId++, quizFrac, 'sa', 'Write two fractions that are equivalent to 3/4.', '[]', '', 10, 3);
|
||||
|
||||
insertItem.run(itemId, mathUnit1, 'lesson', 'Adding & Subtracting Fractions', `
|
||||
<h2>Same Denominator</h2>
|
||||
<p>When denominators are the same, just add or subtract the numerators:</p>
|
||||
<p>3/8 + 2/8 = 5/8</p>
|
||||
<h2>Different Denominators</h2>
|
||||
<p>Find the <strong>Least Common Denominator (LCD)</strong>, convert, then add:</p>
|
||||
<p>1/3 + 1/4 → LCD = 12 → 4/12 + 3/12 = 7/12</p>
|
||||
`, 0, null, 3, daysAgo(7), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertItem.run(itemId, mathUnit1, 'assignment', 'Fractions Worksheet', `
|
||||
Solve these fraction problems. Show your work!
|
||||
|
||||
1. 2/5 + 1/5 =
|
||||
2. 3/4 - 1/4 =
|
||||
3. 1/3 + 1/2 =
|
||||
4. 5/6 - 1/3 =
|
||||
5. Simplify 10/15
|
||||
6. Simplify 9/12
|
||||
7. Are 2/3 and 8/12 equivalent? Explain why.
|
||||
`, 20, null, 4, daysAhead(1), 1, 'none');
|
||||
itemId++;
|
||||
|
||||
insertUnit.run(unitId, 2, 'Geometry', 2);
|
||||
const mathUnit2 = unitId++;
|
||||
|
||||
insertItem.run(itemId, mathUnit2, 'lesson', 'Polygons & Angles', `
|
||||
<h2>What Is a Polygon?</h2>
|
||||
<p>A <strong>polygon</strong> is a closed shape made of straight line segments.</p>
|
||||
<h3>Common Polygons</h3>
|
||||
<ul>
|
||||
<li>Triangle — 3 sides, angles sum to 180°</li>
|
||||
<li>Quadrilateral — 4 sides, angles sum to 360°</li>
|
||||
<li>Pentagon — 5 sides</li>
|
||||
<li>Hexagon — 6 sides</li>
|
||||
</ul>
|
||||
<h3>Types of Angles</h3>
|
||||
<ul>
|
||||
<li><strong>Acute</strong> — less than 90°</li>
|
||||
<li><strong>Right</strong> — exactly 90°</li>
|
||||
<li><strong>Obtuse</strong> — between 90° and 180°</li>
|
||||
<li><strong>Straight</strong> — exactly 180°</li>
|
||||
</ul>
|
||||
`, 0, null, 1, daysAhead(5), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
/* ─── Earth Science ─────────────────────────────────────────
|
||||
Unit 1: The Water Cycle
|
||||
Unit 2: Ecosystems */
|
||||
|
||||
insertUnit.run(unitId, 3, 'The Water Cycle', 1);
|
||||
const sciUnit1 = unitId++;
|
||||
|
||||
insertItem.run(itemId, sciUnit1, 'lesson', 'Water Cycle Overview', `
|
||||
<h2>Earth's Water Is Always Moving</h2>
|
||||
<p>The <strong>water cycle</strong> (also called the hydrological cycle) describes the continuous movement of water through Earth's systems.</p>
|
||||
<h3>The Four Main Stages</h3>
|
||||
<ol>
|
||||
<li><strong>Evaporation</strong> — Heat from the sun turns liquid water into water vapor, which rises into the atmosphere.</li>
|
||||
<li><strong>Condensation</strong> — Water vapor cools and forms tiny droplets that make clouds and fog.</li>
|
||||
<li><strong>Precipitation</strong> — Water falls from clouds as rain, snow, sleet, or hail.</li>
|
||||
<li><strong>Collection</strong> — Water gathers in oceans, lakes, rivers, and groundwater, and the cycle begins again.</li>
|
||||
</ol>
|
||||
<p>The sun is the energy source that drives the water cycle!</p>
|
||||
`, 0, null, 1, daysAgo(18), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertItem.run(itemId, sciUnit1, 'quiz', 'Water Cycle Quiz', '', 25, null, 2, daysAgo(12), 1, 'none');
|
||||
const quizWater = itemId++;
|
||||
insertQ.run(qId++, quizWater, 'mc', 'What is the process where liquid water becomes water vapor?', JSON.stringify(['Condensation','Precipitation','Evaporation','Collection']), 'Evaporation', 5, 1);
|
||||
insertQ.run(qId++, quizWater, 'mc', 'What energy source drives the water cycle?', JSON.stringify(['The moon','Wind','The sun','Earth\'s core']), 'The sun', 5, 2);
|
||||
insertQ.run(qId++, quizWater, 'mc', 'What is it called when water falls from clouds?', JSON.stringify(['Evaporation','Condensation','Collection','Precipitation']), 'Precipitation', 5, 3);
|
||||
insertQ.run(qId++, quizWater, 'mc', 'Which of these is NOT a form of precipitation?', JSON.stringify(['Rain','Snow','Fog','Hail']), 'Fog', 5, 4);
|
||||
insertQ.run(qId++, quizWater, 'sa', 'Explain in your own words what condensation is.', '[]', '', 5, 5);
|
||||
|
||||
insertItem.run(itemId, sciUnit1, 'assignment', 'Water Cycle Diagram', `
|
||||
Draw and label a diagram of the water cycle. Your diagram must include:
|
||||
- At least 4 labeled stages (evaporation, condensation, precipitation, collection)
|
||||
- Arrows showing the direction of movement
|
||||
- The sun as the energy source
|
||||
- At least one body of water (ocean, lake, or river)
|
||||
|
||||
Take a photo of your completed diagram and submit it, or describe it in writing.
|
||||
`, 30, null, 3, daysAhead(2), 0, 'photo');
|
||||
itemId++;
|
||||
|
||||
insertUnit.run(unitId, 3, 'Ecosystems', 2);
|
||||
const sciUnit2 = unitId++;
|
||||
|
||||
insertItem.run(itemId, sciUnit2, 'lesson', 'Food Chains & Food Webs', `
|
||||
<h2>Energy in Ecosystems</h2>
|
||||
<p>All living things need energy. In an ecosystem, energy flows from one organism to another through <strong>food chains</strong>.</p>
|
||||
<h3>Producers</h3>
|
||||
<p>Plants are <strong>producers</strong> — they make their own food using sunlight (photosynthesis).</p>
|
||||
<h3>Consumers</h3>
|
||||
<ul>
|
||||
<li><strong>Primary consumers</strong> — eat plants (herbivores). Example: rabbits, deer, caterpillars</li>
|
||||
<li><strong>Secondary consumers</strong> — eat primary consumers. Example: foxes, frogs, small birds</li>
|
||||
<li><strong>Tertiary consumers</strong> — eat secondary consumers. Example: eagles, sharks, wolves</li>
|
||||
</ul>
|
||||
<h3>Decomposers</h3>
|
||||
<p>Fungi and bacteria break down dead organisms and return nutrients to the soil.</p>
|
||||
`, 0, null, 1, daysAhead(7), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
/* ─── American History ─────────────────────────────────────
|
||||
Unit 1: Colonial America
|
||||
Unit 2: American Revolution */
|
||||
|
||||
insertUnit.run(unitId, 4, 'Colonial America', 1);
|
||||
const histUnit1 = unitId++;
|
||||
|
||||
insertItem.run(itemId, histUnit1, 'lesson', 'The 13 Original Colonies', `
|
||||
<h2>Building a New World</h2>
|
||||
<p>Between 1607 and 1733, thirteen English colonies were established along the eastern coast of North America.</p>
|
||||
<h3>Three Regions</h3>
|
||||
<h4>New England Colonies</h4>
|
||||
<p>Massachusetts, Connecticut, Rhode Island, New Hampshire — Known for fishing, shipbuilding, and trade.</p>
|
||||
<h4>Middle Colonies</h4>
|
||||
<p>New York, New Jersey, Pennsylvania, Delaware — Known as the "breadbasket" for their grain farms.</p>
|
||||
<h4>Southern Colonies</h4>
|
||||
<p>Virginia, Maryland, North Carolina, South Carolina, Georgia — Known for tobacco, rice, and indigo plantations.</p>
|
||||
<p>Life in the colonies was shaped by religion, the land, and relationships with Native Americans.</p>
|
||||
`, 0, null, 1, daysAgo(25), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertItem.run(itemId, histUnit1, 'quiz', 'Colonial America Quiz', '', 20, null, 2, daysAgo(20), 1, 'none');
|
||||
const quizColonial = itemId++;
|
||||
insertQ.run(qId++, quizColonial, 'mc', 'Which region was known as the "breadbasket" colonies?', JSON.stringify(['New England','Middle Colonies','Southern Colonies','Western Territories']), 'Middle Colonies', 5, 1);
|
||||
insertQ.run(qId++, quizColonial, 'mc', 'What was the first permanent English settlement in America?', JSON.stringify(['Plymouth','Boston','Jamestown','Philadelphia']), 'Jamestown', 5, 2);
|
||||
insertQ.run(qId++, quizColonial, 'mc', 'How many original colonies were there?', JSON.stringify(['10','12','13','15']), '13', 5, 3);
|
||||
insertQ.run(qId++, quizColonial, 'sa', 'Name two crops grown in the Southern Colonies.', '[]', '', 5, 4);
|
||||
|
||||
insertItem.run(itemId, histUnit1, 'lesson', 'Daily Life in the Colonies', `
|
||||
<h2>Life in Colonial Times</h2>
|
||||
<p>Colonial children had very different lives from kids today. Most families farmed and made things by hand.</p>
|
||||
<h3>Education</h3>
|
||||
<p>Children in New England attended <strong>dame schools</strong> taught by women in their homes. They learned to read using the <em>New England Primer</em>. Boys might go on to a Latin grammar school; girls usually stayed home to learn household skills.</p>
|
||||
<h3>Work</h3>
|
||||
<p>Children helped with farm chores, cooking, spinning, and candle-making from a young age.</p>
|
||||
<h3>Religion</h3>
|
||||
<p>Religion was central to colonial life. Many colonists came to America seeking religious freedom.</p>
|
||||
`, 0, null, 3, daysAgo(15), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertUnit.run(unitId, 4, 'American Revolution', 2);
|
||||
const histUnit2 = unitId++;
|
||||
|
||||
insertItem.run(itemId, histUnit2, 'lesson', 'Road to Revolution', `
|
||||
<h2>Growing Tensions</h2>
|
||||
<p>By the 1760s, colonists were frustrated with British rule. Several key events pushed them toward revolution.</p>
|
||||
<h3>Taxation Without Representation</h3>
|
||||
<p>Britain taxed the colonies — the <strong>Stamp Act (1765)</strong> and <strong>Townshend Acts (1767)</strong> — without giving them a voice in Parliament. Colonists protested "No taxation without representation!"</p>
|
||||
<h3>Boston Massacre (1770)</h3>
|
||||
<p>British soldiers fired into a crowd of colonists, killing five people. This event inflamed anti-British feelings.</p>
|
||||
<h3>Boston Tea Party (1773)</h3>
|
||||
<p>Colonists dumped 342 chests of British tea into Boston Harbor to protest the Tea Act. Britain responded with the Intolerable Acts.</p>
|
||||
<p>These events pushed the colonies toward declaring independence.</p>
|
||||
`, 0, null, 1, daysAhead(4), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
/* ─── Bible ────────────────────────────────────────────────
|
||||
Unit 1: Old Testament Heroes */
|
||||
|
||||
insertUnit.run(unitId, 5, 'Old Testament Heroes', 1);
|
||||
const bibleUnit1 = unitId++;
|
||||
|
||||
insertItem.run(itemId, bibleUnit1, 'lesson', 'Noah and the Ark', `
|
||||
<h2>A Story of Faith and Obedience</h2>
|
||||
<p>The story of Noah is found in <strong>Genesis 6–9</strong>.</p>
|
||||
<h3>God's Instruction</h3>
|
||||
<p>God saw that the world had become full of wickedness. He chose Noah — a righteous man — and instructed him to build an ark (a large wooden boat) to save his family and two of every kind of animal.</p>
|
||||
<h3>Noah's Obedience</h3>
|
||||
<p>Though it had never rained before (some scholars believe), Noah trusted God and obeyed. He built the ark exactly as God instructed.</p>
|
||||
<h3>The Flood</h3>
|
||||
<p>It rained for <strong>40 days and 40 nights</strong>. The floodwaters covered the earth for 150 days before receding.</p>
|
||||
<h3>God's Promise</h3>
|
||||
<p>After the flood, God placed a <strong>rainbow</strong> in the sky as a sign of His covenant — a promise never to flood the entire earth again.</p>
|
||||
<blockquote>"I have set my rainbow in the clouds, and it will be the sign of the covenant between me and the earth." — Genesis 9:13</blockquote>
|
||||
`, 0, null, 1, daysAgo(10), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
insertItem.run(itemId, bibleUnit1, 'quiz', 'Noah and the Ark Quiz', '', 15, null, 2, daysAgo(7), 1, 'none');
|
||||
const quizNoah = itemId++;
|
||||
insertQ.run(qId++, quizNoah, 'mc', 'Where is the story of Noah found in the Bible?', JSON.stringify(['Exodus','Matthew','Genesis','Psalms']), 'Genesis', 5, 1);
|
||||
insertQ.run(qId++, quizNoah, 'mc', 'How many days and nights did it rain?', JSON.stringify(['7','20','40','100']), '40', 5, 2);
|
||||
insertQ.run(qId++, quizNoah, 'sa', 'What did the rainbow represent after the flood?', '[]', '', 5, 3);
|
||||
|
||||
insertItem.run(itemId, bibleUnit1, 'lesson', 'David and Goliath', `
|
||||
<h2>Courage and Faith</h2>
|
||||
<p>Found in <strong>1 Samuel 17</strong>, the story of David and Goliath shows that with God, the smallest person can overcome great challenges.</p>
|
||||
<h3>The Challenge</h3>
|
||||
<p>Goliath was a Philistine warrior over 9 feet tall who taunted the Israelite army. No soldier was brave enough to fight him — except a young shepherd boy named <strong>David</strong>.</p>
|
||||
<h3>David's Confidence</h3>
|
||||
<p>David told King Saul that God had helped him protect his sheep from lions and bears, so God would help him defeat Goliath too.</p>
|
||||
<h3>The Victory</h3>
|
||||
<p>Armed with only his <strong>sling and five smooth stones</strong>, David ran toward Goliath, slung a stone, and struck the giant in the forehead. Goliath fell, and the Philistines fled.</p>
|
||||
`, 0, null, 3, daysAhead(3), 0, 'none');
|
||||
itemId++;
|
||||
|
||||
console.log('Courses, units, and items created.');
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
SUBMISSIONS (completed work with scores)
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertSub = db.prepare(`
|
||||
INSERT INTO submissions (id, student_id, item_id, status, score, points_possible, answers, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertSubHist = db.prepare(`
|
||||
INSERT INTO submission_history (student_id, item_id, score, points_possible, answers, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
let subId = 1;
|
||||
|
||||
// Emma's completed work
|
||||
// Parts of Speech lesson — done
|
||||
insertSub.run(subId++, 1, lessonPos, 'done', null, 0, null, tsAgo(13));
|
||||
// Parts of Speech Quiz — 15/20
|
||||
const posAnswers = JSON.stringify({ 1: 'mountain', 2: 'jump', 3: 'enormous', 4: 'We went to the park in the morning.' });
|
||||
insertSub.run(subId++, 1, quizPos, 'done', 15, 20, posAnswers, tsAgo(9));
|
||||
insertSubHist.run(1, quizPos, 15, 20, posAnswers, tsAgo(9));
|
||||
|
||||
// Fractions Quiz — 25/30 (Emma)
|
||||
const fracAnswers = JSON.stringify({ 1: '2/3', 2: '4/8', 3: '6/8 and 9/12' });
|
||||
insertSub.run(subId++, 1, quizFrac, 'done', 25, 30, fracAnswers, tsAgo(13));
|
||||
insertSubHist.run(1, quizFrac, 25, 30, fracAnswers, tsAgo(13));
|
||||
|
||||
// Water Cycle Quiz — 20/25 (Emma)
|
||||
const waterAnswers = JSON.stringify({ 1: 'Evaporation', 2: 'The sun', 3: 'Precipitation', 4: 'Fog', 5: 'Condensation is when water vapor cools and turns back into liquid droplets.' });
|
||||
insertSub.run(subId++, 1, quizWater, 'done', 20, 25, waterAnswers, tsAgo(11));
|
||||
insertSubHist.run(1, quizWater, 20, 25, waterAnswers, tsAgo(11));
|
||||
|
||||
// Colonial America Quiz — 18/20 (Emma)
|
||||
const colonialAnswers = JSON.stringify({ 1: 'Middle Colonies', 2: 'Jamestown', 3: '13', 4: 'Tobacco and rice' });
|
||||
insertSub.run(subId++, 1, quizColonial, 'done', 18, 20, colonialAnswers, tsAgo(19));
|
||||
insertSubHist.run(1, quizColonial, 18, 20, colonialAnswers, tsAgo(19));
|
||||
|
||||
// Noah quiz — 12/15 (Emma)
|
||||
const noahAnswers = JSON.stringify({ 1: 'Genesis', 2: '40', 3: "The rainbow was God's promise never to flood the whole earth again." });
|
||||
insertSub.run(subId++, 1, quizNoah, 'done', 12, 15, noahAnswers, tsAgo(6));
|
||||
insertSubHist.run(1, quizNoah, 12, 15, noahAnswers, tsAgo(6));
|
||||
|
||||
// Liam's completed work
|
||||
// Fractions Quiz — 27/30 (Liam, best score)
|
||||
const fracAnswersLiam2 = JSON.stringify({ 1: '2/3', 2: '4/8', 3: '6/8 and 9/12' });
|
||||
insertSub.run(subId++, 2, quizFrac, 'done', 27, 30, fracAnswersLiam2, tsAgo(12));
|
||||
insertSubHist.run(2, quizFrac, 27, 30, fracAnswersLiam2, tsAgo(12));
|
||||
|
||||
// Water Cycle — 22/25 (Liam)
|
||||
const waterLiam = JSON.stringify({ 1: 'Evaporation', 2: 'The sun', 3: 'Precipitation', 4: 'Fog', 5: 'When warm air cools it turns into water droplets.' });
|
||||
insertSub.run(subId++, 2, quizWater, 'done', 22, 25, waterLiam, tsAgo(10));
|
||||
insertSubHist.run(2, quizWater, 22, 25, waterLiam, tsAgo(10));
|
||||
|
||||
// Colonial — 15/20 (Liam)
|
||||
const colonialLiam = JSON.stringify({ 1: 'Middle Colonies', 2: 'Jamestown', 3: '13', 4: 'Tobacco and corn' });
|
||||
insertSub.run(subId++, 2, quizColonial, 'done', 15, 20, colonialLiam, tsAgo(18));
|
||||
insertSubHist.run(2, quizColonial, 15, 20, colonialLiam, tsAgo(18));
|
||||
|
||||
// Parts of Speech (Liam) — 16/20
|
||||
const posLiam = JSON.stringify({ 1: 'mountain', 2: 'jump', 3: 'enormous', 4: 'I put the book on the table.' });
|
||||
insertSub.run(subId++, 2, quizPos, 'done', 16, 20, posLiam, tsAgo(8));
|
||||
insertSubHist.run(2, quizPos, 16, 20, posLiam, tsAgo(8));
|
||||
|
||||
console.log('Submissions created.');
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
SPELLING LISTS
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertList = db.prepare('INSERT INTO lists (id, name, builtin, group_name) VALUES (?, ?, 0, ?)');
|
||||
const insertWord = db.prepare('INSERT INTO words (id, list_id, word, sentence, definition) VALUES (?, ?, ?, ?, ?)');
|
||||
|
||||
let listId = 1, wordId = 1;
|
||||
|
||||
// List 1: 4th Grade Week 1
|
||||
insertList.run(listId, '4th Grade — Week 1', 'Grade 4');
|
||||
const list1 = listId++;
|
||||
const week1words = [
|
||||
['beautiful', 'The sunset was absolutely beautiful.', 'pleasing to the senses or mind'],
|
||||
['necessary', 'It is necessary to drink water every day.', 'needed; essential'],
|
||||
['separate', 'Please separate the red and blue blocks.', 'to set apart; to divide'],
|
||||
['because', 'I stayed inside because it was raining.', 'for the reason that'],
|
||||
['different', 'Each snowflake is different from every other.','not the same as another'],
|
||||
['important', 'Exercise is important for staying healthy.', 'having great significance'],
|
||||
['knowledge', 'Reading books builds your knowledge.', 'facts or skills acquired through learning'],
|
||||
['surprise', 'The party was a complete surprise.', 'an unexpected event or feeling'],
|
||||
['through', 'We walked through the park.', 'moving from one side to the other'],
|
||||
['whether', 'I wasn\'t sure whether to go or stay.', 'expressing a doubt or choice'],
|
||||
];
|
||||
week1words.forEach(([w, s, d]) => insertWord.run(wordId++, list1, w, s, d));
|
||||
|
||||
// List 2: 4th Grade Week 2
|
||||
insertList.run(listId, '4th Grade — Week 2', 'Grade 4');
|
||||
const list2 = listId++;
|
||||
const week2words = [
|
||||
['character', 'The main character in the story was brave.', 'a person in a story; a person\'s qualities'],
|
||||
['complete', 'Did you complete your homework?', 'having all necessary parts; to finish'],
|
||||
['continue', 'We will continue reading tomorrow.', 'to keep going'],
|
||||
['difficult', 'The math problem was very difficult.', 'not easy; hard to do'],
|
||||
['environment', 'We should protect the natural environment.', 'the surroundings in which one lives'],
|
||||
['frequently', 'She frequently visits her grandparents.', 'often; many times'],
|
||||
['government', 'The government makes laws for the country.', 'the group that rules a nation'],
|
||||
['language', 'English is a fascinating language.', 'a system of words used to communicate'],
|
||||
['position', 'What position does he play on the team?', 'a place where someone or something is located'],
|
||||
['remember', 'I will always remember that day.', 'to keep in mind; to recall'],
|
||||
];
|
||||
week2words.forEach(([w, s, d]) => insertWord.run(wordId++, list2, w, s, d));
|
||||
|
||||
// List 3: Science Vocabulary
|
||||
insertList.run(listId, 'Science Vocabulary', 'Science');
|
||||
const list3 = listId++;
|
||||
const sciWords = [
|
||||
['evaporation', 'Evaporation turns liquid water into vapor.', 'process of liquid changing to gas'],
|
||||
['condensation', 'Condensation forms on a cold glass on a hot day.', 'process of gas changing to liquid'],
|
||||
['precipitation','Precipitation includes rain, snow, and hail.', 'water falling from clouds'],
|
||||
['ecosystem', 'A pond is a small ecosystem.', 'a community of living and nonliving things'],
|
||||
['photosynthesis','Plants use photosynthesis to make food.', 'how plants make food using sunlight'],
|
||||
['organism', 'Every living thing is an organism.', 'any living thing'],
|
||||
['hypothesis', 'We formed a hypothesis before the experiment.', 'an educated guess to be tested'],
|
||||
['atmosphere', 'The atmosphere protects Earth from the sun\'s rays.', 'the layer of gases around a planet'],
|
||||
];
|
||||
sciWords.forEach(([w, s, d]) => insertWord.run(wordId++, list3, w, s, d));
|
||||
|
||||
// List 4: Bible Memory Words
|
||||
insertList.run(listId, 'Bible Memory Words', 'Bible');
|
||||
const list4 = listId++;
|
||||
const bibleWords = [
|
||||
['covenant', 'God made a covenant with Noah.', 'a serious agreement or promise'],
|
||||
['righteous', 'Noah was a righteous man.', 'morally right; following God\'s ways'],
|
||||
['obedient', 'David was obedient to God\'s calling.', 'doing what you are told; compliant'],
|
||||
['sacrifice', 'Abraham trusted God with the sacrifice.', 'giving something valuable for a purpose'],
|
||||
['steadfast', 'Be steadfast in your faith.', 'firm and unwavering'],
|
||||
['gracious', 'God is gracious and full of compassion.', 'showing grace; kind and merciful'],
|
||||
];
|
||||
bibleWords.forEach(([w, s, d]) => insertWord.run(wordId++, list4, w, s, d));
|
||||
|
||||
console.log('Spelling lists and words created.');
|
||||
|
||||
/* ── assignments (one active list per student) ─────────────── */
|
||||
const insertAssignment = db.prepare('INSERT OR REPLACE INTO assignments (student_id, list_id, assigned_at) VALUES (?, ?, ?)');
|
||||
insertAssignment.run(1, list2, tsAgo(7)); // Emma → Week 2 (current)
|
||||
insertAssignment.run(2, list1, tsAgo(14)); // Liam → Week 1 (current)
|
||||
|
||||
/* ── spelling progress (Leitner boxes) ────────────────────── */
|
||||
const insertProgress = db.prepare(`
|
||||
INSERT INTO progress (student_id, word_id, box, due, attempts, correct)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
// Emma has practiced all of Week 1 — most words are in boxes 3-5
|
||||
const emmaWordIds = Array.from({length: 10}, (_, i) => i + 1); // words 1-10 are list1
|
||||
const boxDist = [5,5,4,4,3,3,3,2,4,5];
|
||||
emmaWordIds.forEach((wid, i) => {
|
||||
const box = boxDist[i];
|
||||
const due = box >= 4 ? daysAhead(7) : box === 3 ? daysAhead(2) : today();
|
||||
insertProgress.run(1, wid, box, due, 4 + box, 3 + box);
|
||||
});
|
||||
|
||||
// Emma — Week 2 words partially practiced (boxes 1-3)
|
||||
const week2ids = Array.from({length: 10}, (_, i) => i + 11);
|
||||
week2ids.forEach((wid, i) => {
|
||||
const box = Math.min(3, 1 + Math.floor(i / 4));
|
||||
insertProgress.run(1, wid, box, i < 5 ? today() : daysAhead(1), 1 + box, box);
|
||||
});
|
||||
|
||||
// Liam — Week 1 partially (boxes 1-3)
|
||||
emmaWordIds.forEach((wid, i) => {
|
||||
const box = Math.min(3, 1 + Math.floor(i / 4));
|
||||
insertProgress.run(2, wid, box, i < 4 ? today() : daysAhead(1), 1 + box, box);
|
||||
});
|
||||
|
||||
/* ── spelling tests ───────────────────────────────────────── */
|
||||
const insertTest = db.prepare('INSERT INTO tests (id, student_id, list_id, score, total, at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
const insertTA = db.prepare('INSERT INTO test_answers (test_id, word_id, typed, correct) VALUES (?, ?, ?, ?)');
|
||||
|
||||
// Emma — 3 spelling tests
|
||||
insertTest.run(1, 1, list1, 8, 10, tsAgo(12));
|
||||
// test answers for test 1
|
||||
emmaWordIds.slice(0, 10).forEach((wid, i) => {
|
||||
const word = week1words[i][0];
|
||||
const right = i < 8;
|
||||
const typed = right ? word : (i === 8 ? 'thrugh' : 'wether');
|
||||
insertTA.run(1, wid, typed, right ? 1 : 0);
|
||||
});
|
||||
|
||||
insertTest.run(2, 1, list1, 10, 10, tsAgo(5));
|
||||
emmaWordIds.slice(0, 10).forEach((wid, i) => {
|
||||
insertTA.run(2, wid, week1words[i][0], 1);
|
||||
});
|
||||
|
||||
insertTest.run(3, 1, list3, 7, 8, tsAgo(2));
|
||||
Array.from({length: 8}, (_, i) => i + 21).forEach((wid, i) => {
|
||||
const right = i < 7;
|
||||
insertTA.run(3, wid, right ? sciWords[i][0] : 'atmosfere', right ? 1 : 0);
|
||||
});
|
||||
|
||||
// Liam — 2 spelling tests
|
||||
insertTest.run(4, 2, list1, 7, 10, tsAgo(11));
|
||||
emmaWordIds.slice(0, 10).forEach((wid, i) => {
|
||||
const right = i < 7;
|
||||
const word = week1words[i][0];
|
||||
insertTA.run(4, wid, right ? word : word.replace(/[aeiou]/, ''), right ? 1 : 0);
|
||||
});
|
||||
|
||||
insertTest.run(5, 2, list1, 9, 10, tsAgo(4));
|
||||
emmaWordIds.slice(0, 10).forEach((wid, i) => {
|
||||
const right = i !== 4;
|
||||
insertTA.run(5, wid, right ? week1words[i][0] : 'diffrent', right ? 1 : 0);
|
||||
});
|
||||
|
||||
console.log('Spelling tests created.');
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
FLASHCARD DECKS
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertDeck = db.prepare('INSERT INTO decks (id, name, builtin, group_name) VALUES (?, ?, 0, ?)');
|
||||
const insertCard = db.prepare('INSERT INTO cards (id, deck_id, front, back) VALUES (?, ?, ?, ?)');
|
||||
|
||||
let deckId = 1, cardId = 1;
|
||||
|
||||
insertDeck.run(deckId, 'Math Facts — Multiplication', 'Math');
|
||||
const deck1 = deckId++;
|
||||
const multFacts = [
|
||||
['6 × 7', '42'], ['6 × 8', '48'], ['7 × 7', '49'], ['7 × 8', '56'],
|
||||
['8 × 8', '64'], ['8 × 9', '72'], ['9 × 7', '63'], ['9 × 9', '81'],
|
||||
['6 × 9', '54'], ['7 × 9', '63'], ['4 × 8', '32'], ['5 × 7', '35'],
|
||||
];
|
||||
multFacts.forEach(([f, b]) => insertCard.run(cardId++, deck1, f, b));
|
||||
|
||||
insertDeck.run(deckId, 'US States & Capitals', 'Geography');
|
||||
const deck2 = deckId++;
|
||||
const states = [
|
||||
['California', 'Sacramento'], ['Texas', 'Austin'], ['Florida', 'Tallahassee'],
|
||||
['New York', 'Albany'], ['Pennsylvania', 'Harrisburg'], ['Ohio', 'Columbus'],
|
||||
['Virginia', 'Richmond'], ['Georgia', 'Atlanta'], ['Michigan', 'Lansing'],
|
||||
['Massachusetts', 'Boston'], ['Illinois', 'Springfield'], ['Arizona', 'Phoenix'],
|
||||
];
|
||||
states.forEach(([f, b]) => insertCard.run(cardId++, deck2, `Capital of ${f}`, b));
|
||||
|
||||
insertDeck.run(deckId, 'Science Vocabulary', 'Science');
|
||||
const deck3 = deckId++;
|
||||
sciWords.forEach(([w, , d]) => insertCard.run(cardId++, deck3, w, d));
|
||||
|
||||
insertDeck.run(deckId, 'Grammar Terms', 'English');
|
||||
const deck4 = deckId++;
|
||||
const grammarCards = [
|
||||
['Noun', 'A word that names a person, place, thing, or idea'],
|
||||
['Verb', 'A word that shows action or state of being'],
|
||||
['Adjective', 'A word that describes a noun'],
|
||||
['Adverb', 'A word that describes a verb, adjective, or another adverb'],
|
||||
['Pronoun', 'A word that takes the place of a noun'],
|
||||
['Preposition', 'A word that shows relationship (in, on, under, beside)'],
|
||||
['Conjunction', 'A word that joins words or clauses (and, but, or, because)'],
|
||||
['Subject', 'Who or what the sentence is about'],
|
||||
['Predicate', 'What the subject does or is'],
|
||||
['Independent clause', 'A group of words that can stand alone as a sentence'],
|
||||
];
|
||||
grammarCards.forEach(([f, b]) => insertCard.run(cardId++, deck4, f, b));
|
||||
|
||||
console.log('Flashcard decks created.');
|
||||
|
||||
/* ── card progress (Emma has practiced Math deck) ─────────── */
|
||||
const insertCardProg = db.prepare(`
|
||||
INSERT INTO card_progress (student_id, card_id, box, due, attempts, correct)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const mathCardIds = Array.from({length: 12}, (_, i) => i + 1);
|
||||
const mathBoxes = [5,5,4,4,3,3,5,2,4,3,4,5];
|
||||
mathCardIds.forEach((cid, i) => {
|
||||
const box = mathBoxes[i];
|
||||
const due = box >= 4 ? daysAhead(5) : box === 3 ? daysAhead(1) : today();
|
||||
insertCardProg.run(1, cid, box, due, 3 + box, 2 + box);
|
||||
});
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
SCHEDULE (planner entries for this week)
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertSchedule = db.prepare(`
|
||||
INSERT INTO schedule (student_id, date, item_id, title, done, sort, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending')
|
||||
`);
|
||||
|
||||
// Get day-of-week offsets so we land Mon–Fri
|
||||
const getWeekday = (offset) => {
|
||||
const d = new Date();
|
||||
const day = d.getDay(); // 0=Sun
|
||||
const monday = new Date(d);
|
||||
monday.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
|
||||
monday.setDate(monday.getDate() + offset);
|
||||
return monday.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const mon = getWeekday(0), tue = getWeekday(1), wed = getWeekday(2),
|
||||
thu = getWeekday(3), fri = getWeekday(4);
|
||||
|
||||
// Emma's schedule this week
|
||||
insertSchedule.run(1, mon, null, 'Morning Bible reading — Genesis 7', 1, 1);
|
||||
insertSchedule.run(1, mon, null, 'Spelling practice — Week 2 words', 0, 2);
|
||||
insertSchedule.run(1, tue, null, 'Math: Adding Fractions worksheet', 0, 1);
|
||||
insertSchedule.run(1, tue, null, 'History reading: Road to Revolution', 0, 2);
|
||||
insertSchedule.run(1, wed, null, 'Science: Water Cycle review', 0, 1);
|
||||
insertSchedule.run(1, wed, null, 'Language Arts: Descriptive paragraph draft', 0, 2);
|
||||
insertSchedule.run(1, thu, null, 'Spelling test — Week 2', 0, 1);
|
||||
insertSchedule.run(1, thu, null, 'Math: Geometry lesson', 0, 2);
|
||||
insertSchedule.run(1, fri, null, 'Free reading (30 min)', 0, 1);
|
||||
insertSchedule.run(1, fri, null, 'Review week — all subjects', 0, 2);
|
||||
|
||||
// Liam's schedule
|
||||
insertSchedule.run(2, mon, null, 'Spelling practice — Week 2 words', 0, 1);
|
||||
insertSchedule.run(2, mon, null, 'Math: Fractions review', 0, 2);
|
||||
insertSchedule.run(2, tue, null, 'Science: Water Cycle notes', 0, 1);
|
||||
insertSchedule.run(2, tue, null, 'Language Arts: Parts of Speech', 0, 2);
|
||||
insertSchedule.run(2, wed, null, 'History: Colonial Life lesson', 0, 1);
|
||||
insertSchedule.run(2, wed, null, 'Math: Geometry intro', 0, 2);
|
||||
insertSchedule.run(2, thu, null, 'Spelling test — Week 2', 0, 1);
|
||||
insertSchedule.run(2, fri, null, 'Free reading (30 min)', 0, 1);
|
||||
|
||||
console.log('Schedule created.');
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
MESSAGES
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const insertMsg = db.prepare(`
|
||||
INSERT INTO messages (student_id, sender, body, sent_at, read_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertMsg.run(1, 'parent', 'Great job on your spelling test, Emma! 10/10! 🎉', tsAgo(5), tsAgo(5));
|
||||
insertMsg.run(1, 'student', 'Thanks! I practiced a lot. Can we do science experiments today?', tsAgo(5), tsAgo(5));
|
||||
insertMsg.run(1, 'parent', 'I love that idea! Let\'s do the water cycle experiment after lunch.', tsAgo(5), tsAgo(5));
|
||||
insertMsg.run(2, 'parent', 'Liam, remember to finish your fractions worksheet before dinner.', tsAgo(2), tsAgo(2));
|
||||
insertMsg.run(2, 'student', 'Ok! I already did problems 1-4, just have 5-7 left.', tsAgo(2), null);
|
||||
|
||||
console.log('Messages created.');
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
SETTINGS
|
||||
═════════════════════════════════════════════════════════════ */
|
||||
const upsertSetting = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
|
||||
upsertSetting.run('school_name', 'Emmert Academy');
|
||||
upsertSetting.run('timezone', 'America/Chicago');
|
||||
|
||||
console.log('\n✅ Demo data seeded successfully!');
|
||||
console.log(' Students: Emma (🦊) and Liam (🐻)');
|
||||
console.log(' Courses: Language Arts, Math, Earth Science, American History, Bible');
|
||||
console.log(' Spelling: 4 lists with 36 words total');
|
||||
console.log(' Flashcards: 4 decks with', 12 + 12 + 8 + 10, 'cards');
|
||||
console.log(' Quiz submissions with scores for both students');
|
||||
console.log(' Weekly planner schedules');
|
||||
@@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import mammoth from 'mammoth';
|
||||
import multer from 'multer';
|
||||
import nspell from 'nspell';
|
||||
import dictionaryEn from 'dictionary-en';
|
||||
import { db, sha256 } from './db.js';
|
||||
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -15,13 +17,32 @@ const TTS_CACHE = join(DATA_DIR, 'tts-cache');
|
||||
mkdirSync(TTS_CACHE, { recursive: true });
|
||||
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
||||
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
const DOCS_DIR = join(DATA_DIR, 'docs');
|
||||
mkdirSync(DOCS_DIR, { recursive: true });
|
||||
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
|
||||
const SERVER_URL = (process.env.SERVER_URL || 'http://localhost:3002').replace(/\/$/, '');
|
||||
const ONLYOFFICE_JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
|
||||
|
||||
function jwtSign(payload) {
|
||||
if (!ONLYOFFICE_JWT_SECRET) return undefined;
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const sig = createHmac('sha256', ONLYOFFICE_JWT_SECRET).update(`${header}.${body}`).digest('base64url');
|
||||
return `${header}.${body}.${sig}`;
|
||||
}
|
||||
const PIPER_BIN = process.env.PIPER_BIN || 'piper';
|
||||
const PIPER_MODEL = process.env.PIPER_MODEL || join(ROOT, 'voices', 'en_US-lessac-medium.onnx');
|
||||
const PIPER_OK = existsSync(PIPER_MODEL);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '15mb' })); // photos for page-scanning are base64 in the JSON body
|
||||
app.use(express.static(join(ROOT, 'public')));
|
||||
app.use(express.static(join(ROOT, 'public'), {
|
||||
setHeaders(res, filePath) {
|
||||
if (filePath.endsWith('.css') || filePath.endsWith('.js') || filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const docxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
|
||||
|
||||
@@ -416,6 +437,16 @@ app.get('/api/state/:studentId', (req, res) => {
|
||||
res.json({ student: { ...student, isBirthdayWeek: isBirthdayWeek(student.birthday) }, assignment, listProgress });
|
||||
});
|
||||
|
||||
// Free-type spell check: any word, checked against a bundled English dictionary
|
||||
const speller = nspell(dictionaryEn);
|
||||
app.get('/api/spellcheck', (req, res) => {
|
||||
const word = String(req.query.word || '').trim();
|
||||
if (!/^[A-Za-z'’-]{1,60}$/.test(word)) return res.status(400).json({ error: 'Type a single word using letters only.' });
|
||||
const clean = word.replace(/’/g, "'");
|
||||
const correct = speller.correct(clean) || speller.correct(clean.toLowerCase());
|
||||
res.json({ word, correct, suggestions: correct ? [] : speller.suggest(clean).slice(0, 4) });
|
||||
});
|
||||
|
||||
// Build a practice session: the target list's unmastered words only.
|
||||
// listId overrides the student's pinned weekly assignment (used when launched from a course item).
|
||||
app.get('/api/session/:studentId', (req, res) => {
|
||||
@@ -829,6 +860,24 @@ app.post('/api/parent/pin', requirePin, (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/schedule', requirePin, (req, res) => {
|
||||
const { date } = req.query;
|
||||
const d = date || new Date().toISOString().slice(0, 10);
|
||||
const rows = db.prepare(`SELECT * FROM schedule WHERE date = ? ORDER BY student_id, sort`).all(d);
|
||||
res.json({ rows });
|
||||
});
|
||||
|
||||
app.get('/api/admin/recent-scores', requirePin, (req, res) => {
|
||||
const scores = db.prepare(`
|
||||
SELECT s.id, s.student_id, i.title, s.score, s.points_possible, s.completed_at
|
||||
FROM submissions s
|
||||
JOIN items i ON i.id = s.item_id
|
||||
ORDER BY s.completed_at DESC
|
||||
LIMIT 20
|
||||
`).all();
|
||||
res.json({ scores });
|
||||
});
|
||||
|
||||
app.get('/api/admin/app-settings', requirePin, (req, res) => {
|
||||
res.json({
|
||||
school_name: getSetting('school_name') || '',
|
||||
@@ -2417,6 +2466,148 @@ app.post('/api/admin/upload-attachment', requirePin, async (req, res) => {
|
||||
return res.json({ url: `/api/attachments/${filename}`, htmlBody, originalName });
|
||||
});
|
||||
|
||||
// ── Documents (OnlyOffice) ────────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/docs/file', express.static(DOCS_DIR));
|
||||
|
||||
app.get('/api/docs', (req, res) => {
|
||||
const { studentId } = req.query;
|
||||
if (!studentId) return res.status(400).json({ error: 'studentId required' });
|
||||
const docs = db.prepare(`SELECT id, title, doc_key, submitted, submitted_at, parent_read, created_at, updated_at FROM documents WHERE student_id = ? ORDER BY updated_at DESC`).all(studentId);
|
||||
res.json({ docs });
|
||||
});
|
||||
|
||||
// Minimal blank DOCX (base64-encoded zip/OOXML)
|
||||
const BLANK_DOCX = Buffer.from(
|
||||
'UEsDBBQAAAAIADtxU13XeYTq8QAAALgBAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH2QzU7DMBCE730Ky9cqccoBIZSkB36OwKE8wMreJFb9J69b2rdn00KREOVozXwz62nXB+/EHjPZGDq5qhspMOhobBg7+b55ru6koALBgIsBO3lEkut+0W6OCUkwHKiTUynpXinSE3qgOiYMrAwxeyj8zKNKoLcworppmlulYygYSlXmDNkvhGgfcYCdK+LpwMr5loyOpHg4e+e6TkJKzmoorKt9ML+Kqq+SmsmThyabaMkGqa6VzOL1jh/0lSfK1qB4g1xewLNRfcRslIl65xmu/0/649o4DFbjhZ/TUo4aiXh77+qL4sGG71+06jR8/wlQSwMEFAAAAAgAO3FTXSAbhuqyAAAALgEAAAsAAABfcmVscy8ucmVsc43Puw6CMBQG4J2naM4uBQdjDIXFmLAafICmPZRGeklbL7y9HRzEODie23fyN93TzOSOIWpnGdRlBQStcFJbxeAynDZ7IDFxK/nsLDJYMELXFs0ZZ57yTZy0jyQjNjKYUvIHSqOY0PBYOo82T0YXDE+5DIp6Lq5cId1W1Y6GTwPagpAVS3rJIPSyBjIsHv/h3ThqgUcnbgZt+vHlayPLPChMDB4uSCrf7TKzQHNKuorZvgBQSwMEFAAAAAgAO3FTXYwOhdB9AAAAnQAAABwAAAB3b3JkL19yZWxzL2RvY3VtZW50LnhtbC5yZWxzVcxBDsIgEIXhvacgs7egC2NMaXc9gNEDTOgIjTAQhhi9vSx1+fLnfeP8TlG9qMqW2cJhMKCIXV439hbut2V/BiUNecWYmSx8SGCeduOVIrb+kbAVUR1hsRBaKxetxQVKKEMuxL08ck3Y+qxeF3RP9KSPxpx0/TWgo/pPnb5QSwMEFAAAAAgAO3FTXf4sp2t6AQAA0wQAABEAAAB3b3JkL2RvY3VtZW50LnhtbKWUzW7DIAzH73uKiHtLMk3TFDXpYdWmXqZK3R6AEtqgAkZAk/XtB/nsNKnK2ktsBP75bzuwWH5LEVXMWA4qQ8k8RhFTFAquDhn6+nybvaDIOqIKIkCxDJ2ZRcv8YVGnBdCTZMpFnqBsWmuaodI5nWJsackksXPJqQELezenIDHs95wyXIMp8GOcxI2nDVBmrU/3SlRFLOpw8i8NNFN+cw9GEueX5oAlMceTnnm6Jo7vuODu7Nnxc4+BDJ2MSjvEbBAUQtJWUGf6CDMlbxuy6jrQZMSGCa8BlC25Hsu4leY3yx5SXSuikgINI0ie7pvBypDamxE4RX7RBknRKr9OTOIJEwmIIWKKhN85eyWScDUmvqk1l8093NfbdwMnPdL4fbS1Og6scC//wepmdFmavU/MtiTaXyBJ0/VBgSE74RX5jkfhj0T5QxT592IHxTm4zULn4bMxjdm6s2BRnVZEZOgjjFUgnC9wdyLYIdAy6jYGN0zcQ4PXP0f5D1BLAQIUAxQAAAAIADtxU13XeYTq8QAAALgBAAATAAAAAAAAAAAAAACAAQAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQDFAAAAAgAO3FTXSAbhuqyAAAALgEAAAsAAAAAAAAAAAAAAIABIgEAAF9yZWxzLy5yZWxzUEsBAhQDFAAAAAgAO3FTXYwOhdB9AAAAnQAAABwAAAAAAAAAAAAAAIAB/QEAAHdvcmQvX3JlbHMvZG9jdW1lbnQueG1sLnJlbHNQSwECFAMUAAAACAA7cVNd/iyna3oBAADTBAAAEQAAAAAAAAAAAAAAgAG0AgAAd29yZC9kb2N1bWVudC54bWxQSwUGAAAAAAQABAADAQAAXQQAAAAA',
|
||||
'base64'
|
||||
);
|
||||
|
||||
app.post('/api/docs', async (req, res) => {
|
||||
const { studentId, title = 'Untitled Document' } = req.body;
|
||||
if (!studentId) return res.status(400).json({ error: 'studentId required' });
|
||||
const key = randomUUID();
|
||||
const filename = `${key}.docx`;
|
||||
await writeFile(join(DOCS_DIR, filename), BLANK_DOCX);
|
||||
const id = db.prepare(`INSERT INTO documents (student_id, title, doc_key, filename) VALUES (?, ?, ?, ?)`).run(studentId, title, key, filename).lastInsertRowid;
|
||||
res.json({ id, key, title });
|
||||
});
|
||||
|
||||
app.put('/api/docs/:id/title', (req, res) => {
|
||||
const { title } = req.body;
|
||||
db.prepare(`UPDATE documents SET title = ?, updated_at = datetime('now') WHERE id = ?`).run(title, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/docs/:id/submit', (req, res) => {
|
||||
const doc = db.prepare(`SELECT * FROM documents WHERE id = ?`).get(req.params.id);
|
||||
if (!doc) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare(`UPDATE documents SET submitted = 1, submitted_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`).run(doc.id);
|
||||
db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'student', ?)`).run(doc.student_id, `📄 Submitted document: "${doc.title}"`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/docs/:id', async (req, res) => {
|
||||
const doc = db.prepare(`SELECT * FROM documents WHERE id = ?`).get(req.params.id);
|
||||
if (!doc) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare(`DELETE FROM documents WHERE id = ?`).run(doc.id);
|
||||
try { await unlink(join(DOCS_DIR, doc.filename)); } catch { /* already gone */ }
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// OnlyOffice save callback
|
||||
app.post('/api/docs/callback/:key', express.json(), async (req, res) => {
|
||||
const { status, url } = req.body;
|
||||
// status 2 = ready to save, 6 = force-save
|
||||
if (status === 2 || status === 6) {
|
||||
const doc = db.prepare(`SELECT * FROM documents WHERE doc_key = ?`).get(req.params.key);
|
||||
if (doc && url) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
await writeFile(join(DOCS_DIR, doc.filename), buf);
|
||||
db.prepare(`UPDATE documents SET updated_at = datetime('now') WHERE doc_key = ?`).run(req.params.key);
|
||||
} catch { /* save failed silently */ }
|
||||
}
|
||||
}
|
||||
res.json({ error: 0 });
|
||||
});
|
||||
|
||||
// Admin: list submitted docs
|
||||
app.get('/api/admin/docs', requirePin, (req, res) => {
|
||||
const docs = db.prepare(`
|
||||
SELECT d.id, d.title, d.doc_key, d.filename, d.submitted_at, d.parent_read, d.updated_at,
|
||||
s.name AS student_name
|
||||
FROM documents d JOIN students s ON s.id = d.student_id
|
||||
WHERE d.submitted = 1
|
||||
ORDER BY d.submitted_at DESC
|
||||
`).all();
|
||||
res.json({ docs });
|
||||
});
|
||||
|
||||
app.put('/api/admin/docs/:id/read', requirePin, (req, res) => {
|
||||
db.prepare(`UPDATE documents SET parent_read = 1 WHERE id = ?`).run(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/docs/config', requirePin, (req, res) => {
|
||||
res.json({ onlyofficeUrl: ONLYOFFICE_URL, serverUrl: SERVER_URL });
|
||||
});
|
||||
|
||||
app.get('/api/docs/config', (req, res) => {
|
||||
res.json({ onlyofficeUrl: ONLYOFFICE_URL, serverUrl: SERVER_URL });
|
||||
});
|
||||
|
||||
// Returns a fully-signed OnlyOffice editor config — parent comment-only view (PIN required)
|
||||
app.get('/api/admin/docs/bykey/:key/editor-config', requirePin, (req, res) => {
|
||||
const doc = db.prepare(`SELECT d.*, s.name AS student_name FROM documents d JOIN students s ON s.id = d.student_id WHERE d.doc_key = ?`).get(req.params.key);
|
||||
if (!doc) return res.status(404).json({ error: 'Not found' });
|
||||
const config = {
|
||||
document: { fileType: 'docx', key: doc.doc_key, title: doc.title, url: `${SERVER_URL}/api/docs/file/${doc.filename}`, permissions: { edit: false, comment: true, download: true, print: true } },
|
||||
editorConfig: { user: { id: 'parent', name: 'Parent' }, mode: 'edit', lang: 'en', customization: { logo: { visible: false }, compactHeader: true, toolbarNoTabs: true } },
|
||||
type: 'desktop', height: '100%', width: '100%',
|
||||
};
|
||||
const token = jwtSign(config);
|
||||
if (token) config.token = token;
|
||||
res.json({ onlyofficeUrl: ONLYOFFICE_URL, config });
|
||||
});
|
||||
|
||||
// Returns a fully-signed OnlyOffice editor config for a specific document
|
||||
app.get('/api/docs/:id/editor-config', (req, res) => {
|
||||
const doc = db.prepare(`SELECT d.*, s.name AS student_name FROM documents d JOIN students s ON s.id = d.student_id WHERE d.id = ?`).get(req.params.id);
|
||||
if (!doc) return res.status(404).json({ error: 'Not found' });
|
||||
const config = {
|
||||
document: {
|
||||
fileType: 'docx',
|
||||
key: doc.doc_key,
|
||||
title: doc.title,
|
||||
url: `${SERVER_URL}/api/docs/file/${doc.filename}`,
|
||||
permissions: { edit: true, download: true, print: true },
|
||||
},
|
||||
editorConfig: {
|
||||
callbackUrl: `${SERVER_URL}/api/docs/callback/${doc.doc_key}`,
|
||||
user: { id: String(doc.student_id), name: doc.student_name },
|
||||
mode: 'edit',
|
||||
lang: 'en',
|
||||
customization: {
|
||||
autosave: true,
|
||||
forcesave: true,
|
||||
logo: { visible: false },
|
||||
compactHeader: true,
|
||||
toolbarNoTabs: true,
|
||||
},
|
||||
},
|
||||
type: 'desktop',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
};
|
||||
const token = jwtSign(config);
|
||||
if (token) config.token = token;
|
||||
res.json({ onlyofficeUrl: ONLYOFFICE_URL, config });
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
Reference in new issue
Block a user