// ---------- tiny helpers ---------- const $ = (sel) => document.querySelector(sel); const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]) ); sessionStorage.removeItem('pin'); let parentPin = null; async function api(path, opts = {}) { const headers = { 'Content-Type': 'application/json' }; if (parentPin) headers['x-pin'] = parentPin; const res = await fetch(path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined, }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Request failed'); return data; } let _toastTimer = null; function msg(text, type = 'success') { const el = $('#toast'); el.textContent = text; el.className = type === 'error' ? 'toast-error' : ''; el.hidden = false; clearTimeout(_toastTimer); _toastTimer = setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => { el.hidden = true; el.classList.remove('toast-out'); }, 400); }, 3000); } const TYPE_ICON = { lesson: 'πŸ“–', assignment: 'πŸ“', quiz: '❓', spelling_practice: '✏️', spelling_test: '⭐', flashcards: 'πŸ—‚οΈ', }; const TYPE_LABEL = { lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards', }; // ---------- PIN gate ---------- $('#pin-form').addEventListener('submit', async (e) => { e.preventDefault(); const pin = $('#pin-input').value; const { ok } = await api('/api/parent/verify', { method: 'POST', body: { pin } }); if (!ok) return ($('#pin-error').hidden = false); parentPin = pin; $('#pin-error').hidden = true; unlock(); }); async function unlock() { $('#pin-gate').hidden = true; $('#console').hidden = false; showPanel('kids'); loadGradingBadge(); } async function loadGradingBadge() { try { const { count } = await api('/api/grading-queue/count'); const pill = document.querySelector('.nav-pill[data-panel="grading"]'); if (count > 0) { pill.textContent = `βœ… Grading (${count})`; pill.classList.add('grading-pending'); } else { pill.textContent = 'βœ… Grading'; pill.classList.remove('grading-pending'); } } catch {} } if (parentPin) unlock(); // ---------- navigation ---------- document.querySelectorAll('.admin-nav .nav-pill').forEach((pill) => pill.addEventListener('click', () => showPanel(pill.dataset.panel)) ); document.querySelectorAll('[data-nav]').forEach((btn) => btn.addEventListener('click', () => showPanel(btn.dataset.nav)) ); 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)); const loaders = { kids: loadKids, courses: loadCourses, planner: loadPlannerPanel, grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks, settings: loadOllamaConfig, }; if (loaders[name]) loaders[name](); if (name === 'course-detail') openCourseDetail(currentCourseId); } // ============================================================ // Kids // ============================================================ const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a' }; async function loadKids() { const students = await api('/api/students'); $('#student-rows').innerHTML = students.length ? students .map((s) => { const theme = s.theme || 'blue'; const swatches = Object.entries(THEME_COLORS) .map(([t, c]) => ``) .join(''); const streak = s.streak_count >= 2 ? `πŸ”₯ ${s.streak_count} day streak` : ''; return `
${esc(s.emoji)} ${esc(s.name)} ${streak}
${swatches}
`; }) .join('') : `

No kids yet β€” add one below.

`; document.querySelectorAll('[data-student-theme]').forEach((btn) => btn.addEventListener('click', async () => { const studentId = btn.dataset.studentTheme; const theme = btn.dataset.theme; await api(`/api/students/${studentId}`, { method: 'PUT', body: { theme } }); // Update selected state without full reload document.querySelectorAll(`[data-student-theme="${studentId}"]`).forEach((b) => b.classList.toggle('selected', b.dataset.theme === theme) ); }) ); document.querySelectorAll('[data-del-student]').forEach((btn) => btn.addEventListener('click', async () => { if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return; await api(`/api/students/${btn.dataset.delStudent}`, { method: 'DELETE' }); msg('Kid removed.'); loadKids(); }) ); } $('#add-student-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = $('#new-student-name').value.trim(); if (!name) return; await api('/api/students', { method: 'POST', body: { name, emoji: $('#new-student-emoji').value } }); $('#new-student-name').value = ''; msg('Kid added.'); loadKids(); }); // ============================================================ // Courses list + detail (units, items, roster) // ============================================================ let currentCourseId = null; let cachedLists = []; let cachedDecks = []; async function loadCourses() { const courses = await api('/api/admin/courses'); $('#course-rows').innerHTML = courses.length ? courses .map( (c) => `
${esc(c.name)} ${esc(c.subject || '')} ${c.unitCount} unit${c.unitCount === 1 ? '' : 's'}, ${c.itemCount} item${c.itemCount === 1 ? '' : 's'} ${c.archived ? 'archived' : ''}
` ) .join('') : `

No courses yet β€” add one below.

`; document.querySelectorAll('[data-open-course]').forEach((btn) => btn.addEventListener('click', () => { currentCourseId = Number(btn.dataset.openCourse); showPanel('course-detail'); }) ); } $('#add-course-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = $('#new-course-name').value.trim(); if (!name) return; const { id } = await api('/api/courses', { method: 'POST', body: { name, subject: $('#new-course-subject').value.trim(), color: $('#new-course-color').value }, }); $('#new-course-name').value = ''; $('#new-course-subject').value = ''; currentCourseId = id; showPanel('course-detail'); }); async function openCourseDetail(id) { if (!id) return showPanel('courses'); // Preserve unit open/collapsed states and scroll position before re-rendering const existingBlocks = document.querySelectorAll('#cd-units .unit-block'); const savedOpen = Array.from(existingBlocks).map((b) => b.classList.contains('open')); const panelVisible = !document.getElementById('panel-course-detail').hidden; const savedScroll = panelVisible && existingBlocks.length ? window.scrollY : 0; const sameCoursePrev = existingBlocks.length > 0 && id === currentCourseId; const [course, roster] = await Promise.all([ api(`/api/admin/courses/${id}`), api(`/api/courses/${id}/roster`), ]); $('#cd-name').textContent = course.name; $('#cd-name-input').value = course.name; $('#cd-subject-input').value = course.subject || ''; $('#cd-color-input').value = course.color; $('#cd-archived').checked = !!course.archived; $('#cd-roster').innerHTML = roster .map( (s) => `` ) .join(''); document.querySelectorAll('[data-roster-student]').forEach((box) => box.addEventListener('change', async () => { if (box.checked) await api(`/api/courses/${id}/enroll`, { method: 'POST', body: { studentId: box.dataset.rosterStudent } }); else await api(`/api/courses/${id}/enroll/${box.dataset.rosterStudent}`, { method: 'DELETE' }); }) ); $('#cd-units').innerHTML = course.units .map( (u, uIdx) => `
Unit ${uIdx + 1} ${esc(u.name)}
${u.items.length} item${u.items.length !== 1 ? 's' : ''}
${u.items .map( (it) => `
${TYPE_ICON[it.type]} ${esc(it.title)} ${TYPE_LABEL[it.type]}
` ) .join('')}
` ) .join('') || `

No units yet β€” add one below.

`; document.querySelectorAll('#cd-units .unit-toggle').forEach((hdr) => { hdr.addEventListener('click', (e) => { if (e.target.closest('button')) return; const block = hdr.closest('.unit-block'); block.classList.toggle('open'); }); }); document.querySelectorAll('[data-del-unit]').forEach((btn) => btn.addEventListener('click', async () => { if (!confirm('Delete this unit and everything in it?')) return; await api(`/api/units/${btn.dataset.delUnit}`, { method: 'DELETE' }); msg('Unit deleted.'); openCourseDetail(id); }) ); document.querySelectorAll('[data-add-item]').forEach((btn) => btn.addEventListener('click', () => openItemEditor(btn.dataset.addItem, null)) ); document.querySelectorAll('[data-edit-item]').forEach((btn) => btn.addEventListener('click', () => openItemEditor(btn.dataset.unitId, btn.dataset.editItem)) ); $('#cd-print').onclick = () => printCourse(id); // Restore which units were open and the scroll position if (sameCoursePrev && savedOpen.length) { document.querySelectorAll('#cd-units .unit-block').forEach((block, i) => { if (i < savedOpen.length) { block.classList.toggle('open', savedOpen[i]); const btn = block.querySelector('.unit-toggle'); if (btn) btn.setAttribute('aria-expanded', String(savedOpen[i])); } }); } if (savedScroll > 0) requestAnimationFrame(() => window.scrollTo(0, savedScroll)); document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== 'panel-course-detail')); document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.remove('active')); } $('#course-meta-form').addEventListener('submit', async (e) => { e.preventDefault(); await api(`/api/courses/${currentCourseId}`, { method: 'PUT', body: { name: $('#cd-name-input').value.trim(), subject: $('#cd-subject-input').value.trim(), color: $('#cd-color-input').value, archived: $('#cd-archived').checked, }, }); msg('Course saved.'); openCourseDetail(currentCourseId); }); $('#cd-duplicate').addEventListener('click', async () => { const { id } = await api(`/api/courses/${currentCourseId}/duplicate`, { method: 'POST' }); currentCourseId = id; showPanel('course-detail'); msg('Course duplicated β€” update the name and details below.'); }); $('#cd-delete').addEventListener('click', async () => { if (!confirm('Delete this whole course, including all units, items, and grades?')) return; await api(`/api/courses/${currentCourseId}`, { method: 'DELETE' }); msg('Course deleted.'); currentCourseId = null; showPanel('courses'); }); $('#add-unit-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = $('#new-unit-name').value.trim(); if (!name) return; await api('/api/units', { method: 'POST', body: { courseId: currentCourseId, name } }); $('#new-unit-name').value = ''; msg('Unit added.'); openCourseDetail(currentCourseId); }); // ============================================================ // Item editor // ============================================================ let questionCount = 0; let cachedQuizTemplates = []; async function openItemEditor(unitId, itemId) { let courseDetail; [cachedLists, cachedDecks, cachedQuizTemplates, courseDetail] = await Promise.all([ api('/api/lists'), api('/api/decks'), api('/api/quiz-templates'), api(`/api/admin/courses/${currentCourseId}`), ]); $('#ie-list').innerHTML = cachedLists.map((l) => ``).join(''); $('#ie-deck').innerHTML = cachedDecks.map((d) => ``).join(''); // Populate template select $('#ie-template-select').innerHTML = `` + cachedQuizTemplates.map((t) => ``).join(''); // Populate prereq select (all items in course except this one) const allCourseItems = courseDetail.units.flatMap((u) => u.items); $('#ie-prereq').innerHTML = `` + allCourseItems .filter((it) => it.id !== Number(itemId)) .map((it) => ``) .join(''); $('#ie-id').value = itemId || ''; $('#ie-unit-id').value = unitId; $('#ie-error').hidden = true; $('#ie-questions').innerHTML = ''; questionCount = 0; if (itemId) { const item = await api(`/api/admin/items/${itemId}`); $('#ie-heading').textContent = `Edit: ${item.title}`; $('#ie-type').value = item.type; $('#ie-title').value = item.title; $('#ie-body-lesson').innerHTML = item.body || ''; $('#ie-body-assignment').value = item.body; $('#ie-points').value = item.points || 10; $('#ie-due-date').value = item.due_date || ''; $('#ie-allow-retakes').checked = !!item.allow_retakes; $('#ie-prereq').value = item.prereq_item_id || ''; $('#ie-evidence-mode').value = item.evidence_mode || 'none'; $('#ie-retake-policy').value = item.retake_policy || 'latest'; if (item.type === 'spelling_practice' || item.type === 'spelling_test') $('#ie-list').value = item.ref_id; if (item.type === 'flashcards') $('#ie-deck').value = item.ref_id; if (item.type === 'quiz') (item.questions || []).forEach(addQuestionRow); $('#ie-delete').hidden = false; $('#ie-delete').onclick = async () => { if (!confirm('Delete this item and any grades for it?')) return; await api(`/api/items/${itemId}`, { method: 'DELETE' }); msg('Item deleted.'); showPanel('course-detail'); }; } else { $('#ie-heading').textContent = 'New item'; $('#ie-type').value = 'lesson'; $('#ie-title').value = ''; $('#ie-body-lesson').innerHTML = ''; $('#ie-body-assignment').value = ''; $('#ie-points').value = 10; $('#ie-due-date').value = ''; $('#ie-allow-retakes').checked = false; $('#ie-prereq').value = ''; $('#ie-evidence-mode').value = 'none'; $('#ie-retake-policy').value = 'latest'; $('#ie-delete').hidden = true; } updateItemFieldVisibility(); resetScanSection(); resetImportSection(); showPanel('item-editor'); } function updateItemFieldVisibility() { const type = $('#ie-type').value; $('#ie-field-lesson').hidden = type !== 'lesson'; $('#ie-field-assignment').hidden = type !== 'assignment'; $('#ie-field-quiz').hidden = type !== 'quiz'; $('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test'; $('#ie-field-flashcards').hidden = type !== 'flashcards'; $('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz'; $('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' || type === 'flashcards'; } $('#ie-type').addEventListener('change', updateItemFieldVisibility); // ---------- rich text editor (lesson body) ---------- function getLessonBody() { const html = $('#ie-body-lesson').innerHTML.trim(); return html === '
' ? '' : html; } let rtesavedRange = null; // Save selection whenever the editor loses focus (e.g. when clicking a toolbar button) $('#ie-body-lesson').addEventListener('blur', () => { const sel = window.getSelection(); rtesavedRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null; }); function rteRestoreAndExec(cmd, val = null) { $('#ie-body-lesson').focus(); if (rtesavedRange) { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(rtesavedRange); } document.execCommand(cmd, false, val); } // Format buttons (bold, italic, heading, lists, etc.) document.querySelectorAll('.rte-btn[data-cmd]').forEach((btn) => { btn.addEventListener('click', () => { rteRestoreAndExec(btn.dataset.cmd, btn.dataset.val || null); }); }); // Link button $('#rte-link-btn').addEventListener('click', () => { const savedRange = rtesavedRange; const url = prompt('Enter URL (e.g. https://example.com):'); if (url) { rteRestoreAndExec('createLink', url); } }); // YouTube embed button $('#rte-youtube-btn').addEventListener('click', () => { const input = prompt('YouTube URL or video ID:'); if (!input) return; const match = input.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|watch\?v=))([a-zA-Z0-9_-]{11})/); const id = match ? match[1] : input.trim(); if (id) { rteRestoreAndExec( 'insertHTML', `

` ); } }); // ---------- scan a page (photo -> lesson/quiz content via local Ollama) ---------- let scanImageBase64 = null; function resetImportSection() { $('#import-local-input').value = ''; $('#import-local-name').textContent = ''; $('#import-go-btn').disabled = true; $('#import-status').textContent = ''; } function resetScanSection() { scanImageBase64 = null; $('#scan-file-input').value = ''; $('#scan-preview').hidden = true; $('#scan-preview').src = ''; $('#scan-go-btn').disabled = true; $('#scan-status').textContent = ''; } function fileToScanImage(file, maxDim = 1600, quality = 0.85) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('Could not read that file')); reader.onload = () => { const img = new Image(); img.onerror = () => reject(new Error('Could not read that image')); img.onload = () => { let { width, height } = img; if (width > maxDim || height > maxDim) { const scale = maxDim / Math.max(width, height); width = Math.round(width * scale); height = Math.round(height * scale); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; canvas.getContext('2d').drawImage(img, 0, 0, width, height); resolve(canvas.toDataURL('image/jpeg', quality)); }; img.src = reader.result; }; reader.readAsDataURL(file); }); } $('#scan-pick-btn').addEventListener('click', () => $('#scan-file-input').click()); $('#scan-file-input').addEventListener('change', async () => { const file = $('#scan-file-input').files[0]; if (!file) return; try { const dataUrl = await fileToScanImage(file); scanImageBase64 = dataUrl.split(',')[1]; $('#scan-preview').src = dataUrl; $('#scan-preview').hidden = false; $('#scan-go-btn').disabled = false; $('#scan-status').textContent = ''; } catch (err) { $('#scan-status').textContent = `❌ ${err.message}`; } }); $('#scan-go-btn').addEventListener('click', async () => { if (!scanImageBase64) return; const mode = $('#ie-type').value === 'quiz' ? 'quiz' : 'lesson'; $('#scan-go-btn').disabled = true; $('#scan-status').textContent = 'πŸ” Reading the page… this can take a minute or two on local hardware.'; try { const result = await api('/api/admin/scan', { method: 'POST', body: { image: scanImageBase64, mode } }); if (result.title) $('#ie-title').value = result.title; const providerLabel = result._provider === 'cloud' ? 'cloud API' : 'local Ollama'; const fallbackNote = result._fallback ? ` (primary failed β€” used ${providerLabel} as fallback: ${result._primaryError})` : ` via ${providerLabel}`; if (mode === 'lesson') { $('#ie-body-lesson').innerHTML = result.body || ''; $('#scan-status').textContent = `βœ… Scanned${fallbackNote}. Review the text below before saving.`; } else { $('#ie-questions').innerHTML = ''; questionCount = 0; // addQuestionRow expects correct_answer (snake_case); scan returns correctAnswer (camelCase) β€” normalize here. (result.questions || []).forEach((q) => addQuestionRow({ ...q, correct_answer: q.correctAnswer })); const blanks = (result.questions || []).filter((q) => !q.correctAnswer).length; $('#scan-status').textContent = `βœ… Found ${result.questions?.length || 0} question(s)${fallbackNote}` + (blanks ? ` β€” ${blanks} need${blanks === 1 ? 's' : ''} a correct answer` : '') + '. Review before saving.'; } } catch (err) { $('#scan-status').textContent = `❌ ${err.message}`; } finally { $('#scan-go-btn').disabled = false; } }); // ---------- import from local .docx file ---------- $('#import-browse-btn').addEventListener('click', () => $('#import-local-input').click()); $('#import-local-input').addEventListener('change', () => { const file = $('#import-local-input').files[0]; $('#import-local-name').textContent = file ? file.name : ''; $('#import-go-btn').disabled = !file; $('#import-status').textContent = ''; }); $('#import-go-btn').addEventListener('click', async () => { const file = $('#import-local-input').files[0]; if (!file) return; const type = $('#ie-type').value; $('#import-go-btn').disabled = true; $('#import-status').textContent = 'πŸ“„ Extracting text…'; try { const arrayBuffer = await file.arrayBuffer(); const bytes = new Uint8Array(arrayBuffer); let binary = ''; for (let i = 0; i < bytes.length; i += 8192) { binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + 8192, bytes.length))); } const base64 = btoa(binary); const result = await api('/api/admin/import-docx', { method: 'POST', body: { base64 } }); if (!$('#ie-title').value && result.title) $('#ie-title').value = result.title; if (result.body) { const bodyField = type === 'assignment' ? '#ie-body-assignment' : '#ie-body-lesson'; const bodyEl = $(bodyField); if (bodyEl) { bodyEl.value = result.body; $('#import-status').textContent = 'βœ… Text extracted. Review and edit before saving.'; } else { $('#import-status').textContent = 'βœ… Title set. Paste content from your doc manually for quiz questions.'; } } else { $('#import-status').textContent = '⚠️ File imported but no content was found.'; } } catch (err) { $('#import-status').textContent = `❌ ${err.message}`; } finally { $('#import-go-btn').disabled = false; } }); function addQuestionRow(q = {}) { const n = questionCount++; const div = document.createElement('div'); div.className = 'quiz-q-row'; div.dataset.qn = n; div.innerHTML = ` `; div.querySelector('.qn-remove').addEventListener('click', () => div.remove()); $('#ie-questions').appendChild(div); } $('#ie-add-question').addEventListener('click', () => addQuestionRow()); $('#ie-load-template').addEventListener('click', async () => { const tid = $('#ie-template-select').value; if (!tid) return msg('Pick a template first.'); const t = await api(`/api/quiz-templates/${tid}`); $('#ie-questions').innerHTML = ''; questionCount = 0; t.questions.forEach((q) => addQuestionRow({ ...q, correct_answer: q.correct_answer })); msg(`Loaded "${t.name}" β€” ${t.questions.length} question(s).`); }); $('#ie-save-template').addEventListener('click', async () => { const itemId = $('#ie-id').value; if (!itemId) return msg('Save the item first, then save as template.'); const name = prompt('Template name:'); if (!name) return; try { await api('/api/quiz-templates', { method: 'POST', body: { name, itemId: Number(itemId) } }); msg(`Template "${name}" saved.`); cachedQuizTemplates = await api('/api/quiz-templates'); $('#ie-template-select').innerHTML = `` + cachedQuizTemplates.map((t) => ``).join(''); } catch (err) { msg(err.message); } }); $('#item-form').addEventListener('submit', async (e) => { e.preventDefault(); const type = $('#ie-type').value; const title = $('#ie-title').value.trim(); const prereqVal = $('#ie-prereq').value; const body = { unitId: Number($('#ie-unit-id').value), type, title, body: type === 'lesson' ? getLessonBody() : type === 'assignment' ? $('#ie-body-assignment').value : '', points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0, refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value) : type === 'flashcards' ? Number($('#ie-deck').value) : null, dueDate: $('#ie-due-date').value || null, allowRetakes: $('#ie-allow-retakes').checked, prereqItemId: prereqVal ? Number(prereqVal) : null, evidenceMode: $('#ie-evidence-mode').value, retakePolicy: $('#ie-retake-policy').value, }; if (type === 'quiz') { body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({ type: row.querySelector('.qn-type').value, prompt: row.querySelector('.qn-prompt').value.trim(), choices: row.querySelector('.qn-choices').value.split(',').map((c) => c.trim()).filter(Boolean), correctAnswer: row.querySelector('.qn-correct').value.trim(), points: Number(row.querySelector('.qn-points').value) || 1, })); } const id = $('#ie-id').value; try { if (id) await api(`/api/items/${id}`, { method: 'PUT', body }); else await api('/api/items', { method: 'POST', body }); msg('Item saved.'); showPanel('course-detail'); } catch (err) { $('#ie-error').textContent = err.message; $('#ie-error').hidden = false; } }); // ============================================================ // Planner // ============================================================ function mondayOf(d) { const date = new Date(d); const day = date.getDay(); const diff = day === 0 ? -6 : 1 - day; date.setDate(date.getDate() + diff); return date.toISOString().slice(0, 10); } function addDays(dateStr, n) { const d = new Date(dateStr + 'T00:00:00'); d.setDate(d.getDate() + n); return d.toISOString().slice(0, 10); } let plannerWeekStart = mondayOf(new Date()); let plannerQueue = []; let plannerStudentId = null; let plannerCoursesCache = []; let plannerMode = 'week'; // 'week' | 'month' let plannerMonthRef = null; // YYYY-MM-01 of the month being viewed function firstMondayOfMonth(dateStr) { const d = new Date(dateStr + 'T00:00:00'); d.setDate(1); return mondayOf(d); } function addMonths(dateStr, n) { const d = new Date(dateStr + 'T00:00:00'); d.setMonth(d.getMonth() + n); return d.toISOString().slice(0, 10); } function monthLabel(dateStr) { return new Date(dateStr + 'T00:00:00').toLocaleString('default', { month: 'long', year: 'numeric' }); } async function loadPlannerPanel() { const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]); if (students.length === 0) { $('#planner-grid').innerHTML = `

Add a kid first.

`; return; } $('#planner-student').innerHTML = students.map((s) => ``).join(''); if (!plannerStudentId || !students.some((s) => s.id === plannerStudentId)) plannerStudentId = students[0].id; $('#planner-student').value = plannerStudentId; $('#auto-course').innerHTML = courses.map((c) => ``).join(''); if (!$('#auto-start-date').value) $('#auto-start-date').value = plannerWeekStart; await renderPlanner(); } $('#auto-schedule-btn').addEventListener('click', async () => { const courseId = Number($('#auto-course').value); const startDate = $('#auto-start-date').value; const itemsPerDay = Number($('#auto-items-per-day').value) || 1; if (!courseId || !startDate) { $('#auto-schedule-msg').textContent = 'Pick a course and start date.'; return; } const { scheduled } = await api('/api/schedule/auto', { method: 'POST', body: { studentId: plannerStudentId, courseId, startDate, itemsPerDay }, }); $('#auto-schedule-msg').textContent = `Scheduled ${scheduled} item${scheduled === 1 ? '' : 's'} starting ${startDate}.`; plannerWeekStart = mondayOf(startDate + 'T12:00:00'); renderPlanner(); }); $('#planner-student').addEventListener('change', () => { plannerQueue = []; updatePlannerSaveBtn(); plannerStudentId = Number($('#planner-student').value); renderPlanner(); }); $('#planner-view-week').addEventListener('click', () => { plannerMode = 'week'; $('#planner-view-week').classList.add('active-view'); $('#planner-view-month').classList.remove('active-view'); $('#planner-copy').hidden = false; renderPlanner(); }); $('#planner-view-month').addEventListener('click', () => { plannerMode = 'month'; // derive the month from the current week view date const ref = new Date(plannerWeekStart + 'T00:00:00'); plannerMonthRef = `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`; plannerWeekStart = firstMondayOfMonth(plannerMonthRef); $('#planner-view-month').classList.add('active-view'); $('#planner-view-week').classList.remove('active-view'); $('#planner-copy').hidden = true; renderPlanner(); }); $('#planner-prev').addEventListener('click', () => { if (plannerMode === 'month') { plannerMonthRef = addMonths(plannerMonthRef, -1); plannerWeekStart = firstMondayOfMonth(plannerMonthRef); } else { plannerWeekStart = addDays(plannerWeekStart, -7); } renderPlanner(); }); $('#planner-next').addEventListener('click', () => { if (plannerMode === 'month') { plannerMonthRef = addMonths(plannerMonthRef, 1); plannerWeekStart = firstMondayOfMonth(plannerMonthRef); } else { plannerWeekStart = addDays(plannerWeekStart, 7); } renderPlanner(); }); $('#planner-copy').addEventListener('click', async () => { const from = addDays(plannerWeekStart, -7); const { copied } = await api('/api/schedule/copy', { method: 'POST', body: { studentId: plannerStudentId, from, to: plannerWeekStart } }); msg(`Copied ${copied} task${copied === 1 ? '' : 's'} from last week.`); renderPlanner(); }); $('#planner-print').addEventListener('click', () => { if (plannerMode === 'month') printMonthReport(plannerStudentId, plannerWeekStart, plannerMonthRef); else printWeekReport(plannerStudentId, plannerWeekStart); }); const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; function attachPlannerEvents() { let draggingId = null; document.querySelectorAll('.planner-task[draggable]').forEach((el) => { el.addEventListener('dragstart', (e) => { draggingId = el.dataset.taskId; el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); el.addEventListener('dragend', () => { draggingId = null; el.classList.remove('dragging'); document.querySelectorAll('.planner-tasks').forEach((col) => col.classList.remove('drop-target')); }); }); document.querySelectorAll('.planner-tasks').forEach((col) => { col.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; col.classList.add('drop-target'); }); col.addEventListener('dragleave', () => col.classList.remove('drop-target')); col.addEventListener('drop', async (e) => { e.preventDefault(); col.classList.remove('drop-target'); if (!draggingId) return; await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: col.dataset.dropDate } }); renderPlanner(); }); }); document.querySelectorAll('[data-del-schedule]').forEach((btn) => btn.addEventListener('click', async () => { await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' }); renderPlanner(); }) ); document.querySelectorAll('[data-add-task]').forEach((btn) => btn.addEventListener('click', () => toggleAddTaskForm(btn.dataset.addTask)) ); } function renderDayCell(day, tasks, compact = false, spellingTests = []) { const dayTasks = tasks.filter((t) => t.date === day.date); const dayTests = spellingTests.filter((t) => t.date === day.date); const testBadges = dayTests.map((t) => { const pct = Math.round((t.score / t.total) * 100); return `
⭐ Spelling ${t.score}/${t.total} (${pct}%)
`; }).join(''); return `

${compact ? day.name.slice(0,3) : day.name}${compact ? day.date.slice(5) : day.date}

${testBadges} ${dayTasks.map((t) => `
${t.itemId ? TYPE_ICON[t.type] : 'πŸ“Œ'} ${esc(t.itemTitle || t.offlineTitle)}
`).join('') || (!testBadges ? `

β€”

` : '')}
`; } async function renderPlanner() { const courses = await api(`/api/courses/mine/${plannerStudentId}`); plannerCoursesCache = courses; if (plannerMode === 'month') { // Build 4-5 week rows covering the month const month = new Date(plannerMonthRef + 'T00:00:00').getMonth(); const weeks = []; let cursor = plannerWeekStart; for (let w = 0; w < 6; w++) { const weekDays = DAY_NAMES.map((name, i) => ({ name, date: addDays(cursor, i) })); const fridayMonth = new Date(weekDays[4].date + 'T00:00:00').getMonth(); const mondayMonth = new Date(weekDays[0].date + 'T00:00:00').getMonth(); if (w > 0 && mondayMonth !== month && fridayMonth !== month) break; weeks.push(weekDays); cursor = addDays(cursor, 7); } const rangeStart = weeks[0][0].date; const rangeEnd = weeks[weeks.length - 1][4].date; const [{ tasks }, { tests: spellingTests }] = await Promise.all([ api(`/api/schedule-range/${plannerStudentId}?start=${rangeStart}&end=${rangeEnd}`), api(`/api/admin/students/${plannerStudentId}/tests-range?from=${rangeStart}&to=${rangeEnd}`), ]); $('#planner-week-label').textContent = monthLabel(plannerMonthRef); $('#planner-grid').className = 'planner-grid planner-month'; $('#planner-grid').innerHTML = `
${DAY_NAMES.map((d) => `
${d.slice(0,3)}
`).join('')}
` + weeks.map((week) => `
${week.map((day) => renderDayCell(day, tasks, true, spellingTests)).join('')}
` ).join(''); } else { const end = addDays(plannerWeekStart, 4); $('#planner-week-label').textContent = `${plannerWeekStart} β†’ ${end}`; const [{ tasks }, { tests: spellingTests }] = await Promise.all([ api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`), api(`/api/admin/students/${plannerStudentId}/tests-range?from=${plannerWeekStart}&to=${end}`), ]); const days = DAY_NAMES.map((name, i) => ({ name, date: addDays(plannerWeekStart, i) })); $('#planner-grid').className = 'planner-grid'; $('#planner-grid').innerHTML = days.map((day) => renderDayCell(day, tasks, false, spellingTests)).join(''); } attachPlannerEvents(); } function toggleAddTaskForm(date) { const box = document.querySelector(`[data-form-for="${date}"]`); if (!box.hidden) { box.hidden = true; return; } const itemOptions = plannerCoursesCache .map((c) => ``) .join(''); box.hidden = false; box.innerHTML = `
or
`; const courseSelect = box.querySelector('.task-course-select'); (async () => { for (const c of plannerCoursesCache) { const detail = await api(`/api/admin/courses/${c.id}`); const group = document.createElement('optgroup'); group.label = c.name; for (const u of detail.units) { for (const it of u.items) { const opt = document.createElement('option'); opt.value = it.id; opt.textContent = `${TYPE_ICON[it.type]} ${it.title}`; group.appendChild(opt); } } courseSelect.appendChild(group); } })(); box.querySelector('.task-save-btn').addEventListener('click', () => { const itemId = courseSelect.value; const title = box.querySelector('.task-offline-input').value.trim(); if (!itemId && !title) return; const label = itemId ? courseSelect.options[courseSelect.selectedIndex].textContent : `πŸ“Œ ${title}`; plannerQueue.push({ date, itemId: itemId || null, title, label }); updatePlannerSaveBtn(); // Show as pending in the day cell without a full re-render const tasksEl = document.querySelector(`.planner-tasks[data-drop-date="${date}"]`); if (tasksEl) { const hint = tasksEl.querySelector('.hint.tiny'); if (hint) hint.remove(); const el = document.createElement('div'); el.className = 'planner-task planner-pending'; el.textContent = label; tasksEl.appendChild(el); } courseSelect.value = ''; box.querySelector('.task-offline-input').value = ''; }); } function updatePlannerSaveBtn() { const btn = $('#planner-save-all'); const n = plannerQueue.length; btn.hidden = n === 0; btn.textContent = `πŸ’Ύ Save ${n} item${n === 1 ? '' : 's'}`; } $('#planner-save-all').addEventListener('click', async () => { const count = plannerQueue.length; if (!count) return; for (const item of plannerQueue) { await api('/api/schedule', { method: 'POST', body: { studentId: plannerStudentId, date: item.date, itemId: item.itemId, title: item.title }, }); } plannerQueue = []; updatePlannerSaveBtn(); msg(`${count} item${count === 1 ? '' : 's'} added to planner.`); renderPlanner(); }); function renderPrintTaskBlock(t) { const title = t.itemTitle || t.offlineTitle; const course = t.courseName ? `${esc(t.courseName)}` : `Offline`; const status = t.done ? `βœ“ done` : ''; const typeTag = t.type ? `${TYPE_LABEL[t.type] || t.type}` : ''; let body = ''; if (t.body) body = `
${t.body.split(/\n\n+/).map((p) => `

${esc(p.trim())}

`).join('')}
`; return `
${course} ${typeTag} ${esc(title)} ${status}
${body}
`; } async function printWeekReport(studentId, start) { const r = await api(`/api/week-report/${studentId}?start=${start}`); // Group tasks by date const byDate = {}; for (const t of r.tasks) { (byDate[t.date] = byDate[t.date] || []).push(t); } const dayBlocks = Object.entries(byDate).map(([date, tasks]) => `

${date}

${tasks.map(renderPrintTaskBlock).join('')}
` ).join('') || '

Nothing scheduled this week.

'; const gradedRows = r.graded .map((g) => `${esc(g.itemTitle)}${esc(g.courseName)}${g.score}/${g.points_possible}`) .join(''); const spellingRows = r.spellingTests .map((t) => `${esc(t.list)}${t.score}/${t.total}`) .join(''); const win = window.open('', '_blank'); win.document.write(`Weekly Plan β€” ${esc(r.student)}

Weekly Plan β€” ${esc(r.student)}

Week of ${r.start}

Schedule

${dayBlocks}

Graded work

${gradedRows ? `${gradedRows}
ItemCourseScore
` : '

Nothing graded this week.

'}

Spelling tests

${spellingRows ? `${spellingRows}
ListScore
` : '

No spelling tests this week.

'}