// ---------- tiny helpers ---------- const $ = (sel) => document.querySelector(sel); async function api(path, opts = {}) { const headers = { 'Content-Type': 'application/json' }; 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; } function show(name) { document.querySelectorAll('.view').forEach((v) => (v.hidden = true)); $(`#view-${name}`).hidden = false; } const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]) ); const todayStr = () => new Date().toISOString().slice(0, 10); 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', }; document.querySelectorAll('[data-nav]').forEach((btn) => btn.addEventListener('click', () => { speechSynthesis.cancel(); if (btn.dataset.nav === 'home') { loadHome(); show('home'); } }) ); // Sub-views set this before opening so their back/stop button knows where to return let backTarget = () => openKid(currentStudent.id); document.querySelectorAll('[data-back]').forEach((btn) => btn.addEventListener('click', () => { speechSynthesis.cancel(); backTarget(); }) ); // ---------- evidence photo helpers ---------- let evidencePhotoBase64 = null; function compressPhoto(file, maxDim = 1200, quality = 0.75) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('Could not read file')); reader.onload = () => { const img = new Image(); img.onerror = () => reject(new Error('Could not read 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); }); } $('#assignment-photo-btn').addEventListener('click', () => $('#assignment-photo-input').click()); $('#assignment-photo-input').addEventListener('change', async () => { const file = $('#assignment-photo-input').files[0]; if (!file) return; try { const dataUrl = await compressPhoto(file); evidencePhotoBase64 = dataUrl; $('#assignment-photo-status').textContent = 'βœ… Photo ready'; } catch { $('#assignment-photo-status').textContent = '❌ Could not load photo'; } }); // ---------- text to speech (spelling module) ---------- // piperAvailable: null = not yet checked, true/false after first /api/tts/status call let piperAvailable = null; let ttsQueue = []; let ttsCurrentAudio = null; async function checkPiper() { if (piperAvailable !== null) return piperAvailable; try { const { available } = await api('/api/tts/status'); piperAvailable = available; } catch { piperAvailable = false; } return piperAvailable; } function ttsCancel() { ttsQueue = []; if (ttsCurrentAudio) { ttsCurrentAudio.pause(); ttsCurrentAudio = null; } speechSynthesis.cancel(); } async function ttsDrain() { if (!ttsQueue.length) { ttsCurrentAudio = null; return; } const text = ttsQueue.shift(); if (piperAvailable) { const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`); ttsCurrentAudio = audio; await new Promise((resolve) => { audio.onended = resolve; audio.onerror = resolve; audio.play().catch(resolve); }); } else { const u = new SpeechSynthesisUtterance(text); u.rate = 0.85; u.lang = 'en-US'; speechSynthesis.cancel(); await new Promise((resolve) => { u.onend = resolve; u.onerror = resolve; // some browsers need a short delay after cancel() before speak() works setTimeout(() => speechSynthesis.speak(u), 50); }); } ttsDrain(); } async function speakWord(w) { ttsCancel(); await checkPiper(); ttsQueue.push(w.word); if (w.definition) ttsQueue.push(w.definition); if (w.sentence) ttsQueue.push(w.sentence); ttsDrain(); } // ---------- letter diff (LCS alignment, spelling module) ---------- function diffLetters(typed, correct) { const a = typed.toLowerCase(), b = correct.toLowerCase(); const m = a.length, n = b.length; const L = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) L[i][j] = a[i] === b[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]); const typedParts = [], correctParts = []; let i = 0, j = 0; while (i < m && j < n) { if (a[i] === b[j]) { typedParts.push(`${esc(typed[i])}`); correctParts.push(`${esc(correct[j])}`); i++; j++; } else if (L[i + 1][j] >= L[i][j + 1]) { typedParts.push(`${esc(typed[i])}`); i++; } else { correctParts.push(`${esc(correct[j])}`); j++; } } while (i < m) typedParts.push(`${esc(typed[i++])}`); while (j < n) correctParts.push(`${esc(correct[j++])}`); return { typedHtml: typedParts.join(''), correctHtml: correctParts.join('') }; } // ---------- themes ---------- const THEMES = { blue: {}, green: { '--accent': '#2e9e5b', '--accent-dark': '#247a47', '--bg': '#f0faf4' }, purple: { '--accent': '#7c5cbf', '--accent-dark': '#6245a0', '--bg': '#f8f4ff' }, orange: { '--accent': '#e8802a', '--accent-dark': '#c96a1a', '--bg': '#fff8f0' }, pink: { '--accent': '#d45d8a', '--accent-dark': '#b4487a', '--bg': '#fff0f5' }, }; const THEME_NAMES = Object.keys(THEMES); const DEFAULT_VARS = { '--accent': '#4f86f7', '--accent-dark': '#3a6fd8', '--bg': '#fdf6e3' }; function applyTheme(theme) { const vars = THEMES[theme] || {}; for (const [k, v] of Object.entries(DEFAULT_VARS)) document.documentElement.style.setProperty(k, vars[k] || v); document.body.className = theme && theme !== 'blue' ? `theme-${theme}` : ''; } function resetTheme() { for (const k of Object.keys(DEFAULT_VARS)) document.documentElement.style.removeProperty(k); document.body.className = ''; } // ---------- home (kid picker) ---------- let currentStudent = null; async function loadHome() { resetTheme(); const students = await api('/api/students'); $('#no-kids').hidden = students.length > 0; $('#kid-cards').innerHTML = students .map( (s) => `` ) .join(''); document.querySelectorAll('.kid-card').forEach((card) => card.addEventListener('click', () => openKid(Number(card.dataset.id))) ); } // ---------- kid home: Today / Courses / Spelling ---------- document.querySelectorAll('.nav-pill').forEach((pill) => pill.addEventListener('click', () => switchTab(pill.dataset.tab)) ); function switchTab(name) { document.querySelectorAll('.nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.tab === name)); document.querySelectorAll('.tab-panel').forEach((panel) => (panel.hidden = panel.id !== `tab-${name}`)); } async function openKid(id, tab = 'today') { const [state, agenda, courses] = await Promise.all([ api(`/api/state/${id}`), api(`/api/schedule/${id}?date=${todayStr()}`), api(`/api/courses/mine/${id}`), ]); currentStudent = state.student; applyTheme(currentStudent.theme || 'blue'); updateThemeDots(currentStudent.theme || 'blue'); $('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`; // Show streak pill if kid has a streak const existingPill = document.querySelector('.streak-pill'); if (existingPill) existingPill.remove(); if (state.student.streak_count >= 2) { const pill = document.createElement('div'); pill.className = 'streak-pill'; pill.textContent = `πŸ”₯ ${state.student.streak_count} day streak!`; $('#kid-greeting').insertAdjacentElement('afterend', pill); } renderToday(agenda.tasks); renderCourseCards(courses); renderSpellingTab(state); show('kid'); switchTab(tab); // Celebrate if all today's tasks are done if (agenda.tasks.length > 0) { const allDone = agenda.tasks.every((t) => t.done || t.offlineStatus === 'done' || t.subStatus === 'graded' || t.subStatus === 'done' ); if (allDone) { const result = await api(`/api/students/${id}/complete-day`, { method: 'POST' }); if (!result.alreadyCounted) showCelebration(result); } } } function updateThemeDots(active) { document.querySelectorAll('.theme-dot').forEach((btn) => btn.classList.toggle('active', btn.dataset.theme === active) ); } function showCelebration({ streak, best }) { const isRecord = streak > 1 && streak === best; let streakHtml = ''; if (streak >= 2) { streakHtml = `
πŸ”₯ ${streak} days in a row!
${isRecord ? `
πŸ† New record!
` : `
Best: ${best} days
`}`; } else { streakHtml = `
Keep it up β€” come back tomorrow for a streak!
`; } $('#streak-display').innerHTML = streakHtml; $('#celebration-overlay').hidden = false; } $('#celebration-close').addEventListener('click', () => { $('#celebration-overlay').hidden = true; }); // Theme picker toggle $('#theme-picker-btn').addEventListener('click', () => { $('#theme-dots').hidden = !$('#theme-dots').hidden; }); document.querySelectorAll('.theme-dot').forEach((btn) => btn.addEventListener('click', async () => { const theme = btn.dataset.theme; applyTheme(theme); updateThemeDots(theme); currentStudent.theme = theme; await api(`/api/students/${currentStudent.id}/theme`, { method: 'PATCH', body: { theme } }); $('#theme-dots').hidden = true; }) ); const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: 'πŸ”„', done: 'βœ…' }; function renderToday(tasks) { $('#today-empty').hidden = tasks.length > 0; $('#today-list').innerHTML = tasks .map((t) => { if (!t.itemId) { const st = t.offlineStatus || (t.done ? 'done' : 'not_started'); return `
${OFFLINE_STATUS_ICON[st] || '⬜'} ${esc(t.offlineTitle)} πŸ“Œ${t.hasEvidence ? ' πŸ“Ž' : ''}
πŸ“Ž Add notes / photo
`; } const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done); const feedbackBadge = t.parentComment ? `πŸ’¬ feedback` : ''; return ``; }) .join(''); // Offline status buttons document.querySelectorAll('.offline-status-btn').forEach((btn) => btn.addEventListener('click', async () => { await api(`/api/schedule/${btn.dataset.sid}/done`, { method: 'POST', body: { status: btn.dataset.status } }); openKid(currentStudent.id, 'today'); }) ); // Offline evidence photo picker document.querySelectorAll('.today-row-offline').forEach((row) => { const photoBtn = row.querySelector('.offline-photo-btn'); const photoInput = row.querySelector('.offline-photo-input'); const photoStatus = row.querySelector('.offline-photo-status'); photoBtn.addEventListener('click', () => photoInput.click()); photoInput.addEventListener('change', async () => { const file = photoInput.files[0]; if (!file) return; try { photoInput._dataUrl = await compressPhoto(file); photoStatus.textContent = 'βœ… Ready'; } catch { photoStatus.textContent = '❌ Error'; } }); }); // Offline evidence save document.querySelectorAll('.offline-evidence-save').forEach((btn) => btn.addEventListener('click', async () => { const row = btn.closest('.today-row-offline'); const notes = row.querySelector('.offline-notes').value || null; const photoInput = row.querySelector('.offline-photo-input'); const photo = photoInput._dataUrl || null; await api(`/api/schedule/${btn.dataset.sid}/done`, { method: 'POST', body: { evidenceNotes: notes, evidencePhoto: photo } }); btn.textContent = 'βœ… Saved'; setTimeout(() => openKid(currentStudent.id, 'today'), 600); }) ); document.querySelectorAll('.today-row:not(.offline)').forEach((row) => row.addEventListener('click', () => { backTarget = () => openKid(currentStudent.id, 'today'); openItem(Number(row.dataset.itemId), row.dataset.itemType); }) ); } function statusBadge(type, status, score, pointsPossible, done) { if (status === 'graded') return `🌟 ${score}/${pointsPossible}`; if (status === 'done') return type === 'assignment' ? '⏳ grading' : 'βœ… done'; if (done) return 'βœ… done'; return ''; } function renderCourseCards(courses) { $('#courses-empty').hidden = courses.length > 0; $('#course-cards').innerHTML = courses .map((c) => { const pct = c.total > 0 ? Math.round((c.done / c.total) * 100) : 0; return ``; }) .join(''); document.querySelectorAll('.course-card').forEach((card) => card.addEventListener('click', () => openCourse(Number(card.dataset.courseId))) ); } let currentSpellingListId = null; let currentSpellingListName = ''; function renderSpellingTab(state) { let html; if (state.assignment) { const { mastered, total } = state.listProgress; html = `This week: ${esc(state.assignment.name)}
${'🌟'.repeat(mastered)}${'βšͺ'.repeat(Math.max(0, total - mastered))}
${mastered} of ${total} words mastered
`; currentSpellingListId = state.assignment.id; currentSpellingListName = state.assignment.name; } else { html = `No list assigned yet β€” ask a parent to pick one!`; currentSpellingListId = null; } if (state.dueReviews > 0) { html += `
πŸ” ${state.dueReviews} old ${state.dueReviews === 1 ? 'word' : 'words'} due for review
`; } $('#kid-week').innerHTML = html; $('#btn-test').disabled = !state.assignment; $('#btn-print-list').disabled = !state.assignment; } $('#btn-print-list').addEventListener('click', async () => { if (!currentSpellingListId) return; const list = await api(`/api/lists/${currentSpellingListId}`); printSpellingWorksheet(list.name, list.words); }); function printSpellingWorksheet(name, words) { const rows = words .map((w) => { const sentence = w.sentence ? `
${esc(w.sentence)}
` : ''; return ` ${esc(w.word)}${sentence} `; }) .join(''); const win = window.open('', '_blank'); win.document.write(`Spelling: ${esc(name)}

✏️ Spelling: ${esc(name)}

Write each word twice β€” once to practice, once from memory.

${rows}
WordPracticeFrom memory