// ---------- 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, { tests: recentTests }] = await Promise.all([ api(`/api/state/${id}`), api(`/api/schedule/${id}?date=${todayStr()}`), api(`/api/courses/mine/${id}`), api(`/api/students/${id}/tests`), ]); 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, recentTests); 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 = `
Write each word twice β once to practice, once from memory.
| Word | Practice | From memory |
|---|