// ---------- 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: 'โ“', matching: '๐Ÿ”ค', crossword: 'โฌ›', spelling_practice: 'โœ๏ธ', spelling_test: 'โญ', flashcards: '๐Ÿ—‚๏ธ', worksheet: '๐Ÿ“‹', }; const TYPE_LABEL = { lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword', spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards', worksheet: 'Worksheet', }; 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(); // Fallback timeout in case browser never fires onend (common speechSynthesis bug) const timeoutMs = Math.max(4000, text.length * 80); if (piperAvailable) { const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`); ttsCurrentAudio = audio; await new Promise((resolve) => { let t = setTimeout(resolve, timeoutMs); const done = () => { clearTimeout(t); resolve(); }; audio.onended = done; audio.onerror = done; audio.play().catch(done); }); } else { const u = new SpeechSynthesisUtterance(text); u.rate = 0.85; u.lang = 'en-US'; speechSynthesis.cancel(); await new Promise((resolve) => { let t = setTimeout(resolve, timeoutMs); const done = () => { clearTimeout(t); resolve(); }; u.onend = done; u.onerror = done; // Un-stick suspended synthesis (common on mobile after a few minutes) if (speechSynthesis.paused) speechSynthesis.resume(); // 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' }, red: { '--accent': '#e84040', '--accent-dark': '#c42828', '--bg': '#fff5f5' }, teal: { '--accent': '#20a8a0', '--accent-dark': '#178880', '--bg': '#f0faf9' }, yellow: { '--accent': '#c8960c', '--accent-dark': '#a87a06', '--bg': '#fffbf0' }, indigo: { '--accent': '#5b6abf', '--accent-dark': '#4a57a0', '--bg': '#f4f5ff' }, }; const ANIMAL_EMOJIS = [ '๐Ÿถ','๐Ÿฑ','๐Ÿฐ','๐ŸฆŠ','๐Ÿป','๐Ÿผ','๐Ÿจ','๐Ÿฏ', '๐Ÿฆ','๐Ÿฎ','๐Ÿท','๐Ÿธ','๐Ÿต','๐Ÿ”','๐Ÿง','๐Ÿฆ†', '๐Ÿฆ‰','๐Ÿฆ‹','๐Ÿข','๐Ÿฆ„','๐Ÿฌ','๐Ÿฆˆ','๐Ÿฆ–','๐Ÿฆ•', '๐Ÿบ','๐Ÿฆ','๐Ÿฆ”','๐Ÿฟ๏ธ','๐Ÿฆœ','๐Ÿฆฉ','๐Ÿฆš','๐Ÿ™', ]; 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); const bgClass = [...document.body.classList].find(c => c.startsWith('bg-')); document.body.className = theme && theme !== 'blue' ? `theme-${theme}` : ''; if (bgClass) document.body.classList.add(bgClass); } const BG_PATTERNS = ['none','dots','stripes','grid','stars','bubbles']; function applyBgPattern(pattern) { BG_PATTERNS.forEach(p => document.body.classList.remove(`bg-${p}`)); if (pattern && pattern !== 'none') document.body.classList.add(`bg-${pattern}`); document.querySelectorAll('.bg-pattern-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.pattern === (pattern || 'none')) ); } 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() { stopMsgPoll(); resetTheme(); const hour = new Date().getHours(); const greeting = hour < 12 ? 'Good morning! โ˜€๏ธ' : hour < 17 ? 'Good afternoon! ๐ŸŒค๏ธ' : 'Good evening! ๐ŸŒ™'; document.querySelector('.subtitle').textContent = greeting; const pubSettings = await fetch('/api/public-settings').then((r) => r.json()).catch(() => ({})); document.querySelector('.page-confetti').style.display = pubSettings.show_home_emoji === false ? 'none' : ''; const nameTitle = document.getElementById('school-name-title'); if (pubSettings.school_name) { nameTitle.textContent = pubSettings.school_name; document.title = pubSettings.school_name; } const logoEl = document.getElementById('school-logo'); if (pubSettings.has_logo) { logoEl.src = `/api/logo?v=${Date.now()}`; logoEl.hidden = false; nameTitle.hidden = true; } else { logoEl.hidden = true; nameTitle.hidden = false; } const students = await api('/api/students'); $('#no-kids').hidden = students.length > 0; $('#kid-cards').innerHTML = students .map((s, i) => { const accent = (THEMES[s.theme || 'blue'] || {})['--accent'] || DEFAULT_VARS['--accent']; return ``; }) .join(''); document.querySelectorAll('.kid-card').forEach((card) => card.addEventListener('click', () => openKid(Number(card.dataset.id), 'today', true)) ); } // ---------- kid home: Today / Courses / Spelling ---------- let currentCourseItems = []; // flat [{id, type, title}] from most-recently opened course function findNextCourseItem(currentId) { const idx = currentCourseItems.findIndex((it) => it.id === currentId); if (idx === -1 || idx >= currentCourseItems.length - 1) return null; return currentCourseItems[idx + 1]; } function showNextActions(containerId, currentItemId) { const next = findNextCourseItem(currentItemId); const el = document.getElementById(containerId); if (!el) return; if (!next) { backTarget(); return; } el.hidden = false; el.innerHTML = ` `; document.getElementById('next-item-btn').addEventListener('click', () => { el.hidden = true; openItem(next.id, next.type); }); document.getElementById('back-course-btn').addEventListener('click', () => { el.hidden = true; backTarget(); }); } document.querySelectorAll('.nav-pill').forEach((pill) => pill.addEventListener('click', () => switchTab(pill.dataset.tab)) ); const TAB_ORDER = ['today', 'courses', 'spelling', 'inbox', 'docs']; let currentTab = 'today'; function switchTab(name) { const animate = name !== currentTab; const dir = TAB_ORDER.indexOf(name) > TAB_ORDER.indexOf(currentTab) ? 'right' : 'left'; currentTab = name; document.querySelectorAll('.nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.tab === name)); document.querySelectorAll('.tab-panel').forEach((panel) => { const isTarget = panel.id === `tab-${name}`; panel.hidden = !isTarget; if (isTarget && animate) { panel.classList.remove('sliding-right', 'sliding-left'); void panel.offsetWidth; panel.classList.add(dir === 'right' ? 'sliding-right' : 'sliding-left'); } }); if (name === 'inbox') loadInbox(); if (name === 'docs') loadDocs(); } function inboxSeenKey(studentId) { return `inbox_seen_${studentId}`; } function getSeenIds(studentId) { try { return new Set(JSON.parse(localStorage.getItem(inboxSeenKey(studentId)) || '[]')); } catch { return new Set(); } } function markAllSeen(studentId, ids) { localStorage.setItem(inboxSeenKey(studentId), JSON.stringify([...ids])); } function renderGradeCard(r, isNew) { const pct = r.points_possible ? Math.round((r.score / r.points_possible) * 100) : null; const scoreClass = pct !== null ? (pct >= 80 ? 'score-good' : 'score-bad') : ''; const date = new Date(r.graded_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return `
${esc(r.itemTitle)} ${isNew ? '' : ''}
${esc(r.courseName)} ยท ${esc(r.unitName)} ยท ${date}
${r.points_possible !== null ? `
๐ŸŒŸ ${r.score} / ${r.points_possible}${pct !== null ? ` (${pct}%)` : ''}
` : ''} ${r.parent_comment ? `
๐Ÿ’ฌ ${esc(r.parent_comment)}
` : ''}
`; } async function loadInbox() { const sid = currentStudent?.id; if (!sid) return; const [items, msgs] = await Promise.all([ api(`/api/students/${sid}/inbox`), api(`/api/students/${sid}/messages`), ]); const seen = getSeenIds(sid); const newItems = items.filter((r) => !seen.has(r.submissionId)); const gradedItems = items.filter((r) => seen.has(r.submissionId)); $('#inbox-list').innerHTML = newItems.map((r) => renderGradeCard(r, true)).join(''); $('#inbox-empty').hidden = newItems.length > 0; $('#graded-list').innerHTML = gradedItems.map((r) => renderGradeCard(r, false)).join(''); $('#graded-empty').hidden = gradedItems.length > 0; const newBadge = $('#inbox-new-badge'); if (newBadge) { newBadge.hidden = newItems.length === 0; newBadge.textContent = newItems.length; } markAllSeen(sid, items.map((r) => r.submissionId)); updateInboxBadge(sid, 0); _lastParentMsgCount = 0; renderMsgThread(msgs); } document.querySelectorAll('.inbox-subtab').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('.inbox-subtab').forEach((b) => b.classList.remove('active')); btn.classList.add('active'); const sub = btn.dataset.sub; $('#inbox-sub-new').hidden = sub !== 'new'; $('#inbox-sub-graded').hidden = sub !== 'graded'; }); }); function renderMsgThread(msgs) { const thread = $('#msg-thread'); if (!thread) return; if (!msgs.length) { thread.innerHTML = '

No messages yet. Say hi to your parent!

'; return; } thread.innerHTML = msgs.map((m) => { const isStudent = m.sender === 'student'; const time = new Date(m.sent_at + 'Z').toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); return `
${esc(m.body)}
${time}
`; }).join(''); thread.scrollTop = thread.scrollHeight; } $('#msg-compose')?.addEventListener('submit', async (e) => { e.preventDefault(); const input = $('#msg-input'); const body = input.value.trim(); if (!body || !currentStudent) return; input.value = ''; await api(`/api/students/${currentStudent.id}/messages`, { method: 'POST', body: { body } }); const msgs = await api(`/api/students/${currentStudent.id}/messages`); renderMsgThread(msgs); }); function updateInboxBadge(studentId, count) { const badge = $('#inbox-badge'); if (!badge) return; badge.hidden = count === 0; badge.textContent = count; } async function refreshInboxBadge(studentId) { try { const items = await api(`/api/students/${studentId}/inbox`); const seen = getSeenIds(studentId); const unseen = items.filter((r) => !seen.has(r.submissionId)).length; updateInboxBadge(studentId, unseen); } catch { /* non-critical */ } } let _msgPollInterval = null; let _lastParentMsgCount = -1; function showMsgNotifToast() { const existing = document.getElementById('msg-notif-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.id = 'msg-notif-toast'; toast.className = 'msg-notif-toast'; toast.innerHTML = '๐Ÿ’ฌ New message from your parent! Tap to read'; toast.addEventListener('click', () => { toast.remove(); switchTab('inbox'); }); document.body.appendChild(toast); setTimeout(() => toast.classList.add('msg-notif-toast-show'), 10); setTimeout(() => { toast.classList.remove('msg-notif-toast-show'); setTimeout(() => toast.remove(), 400); }, 6000); } function requestNotifPermission() { if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } } function fireOsNotif(title, body, onClick) { if (!('Notification' in window) || Notification.permission !== 'granted') return; const n = new Notification(title, { body, icon: '/favicon.ico' }); if (onClick) n.onclick = () => { window.focus(); onClick(); n.close(); }; } function startMsgPoll(studentId) { clearInterval(_msgPollInterval); _lastParentMsgCount = -1; _msgPollInterval = setInterval(async () => { try { const { count } = await api(`/api/students/${studentId}/messages/count`); if (_lastParentMsgCount >= 0 && count > _lastParentMsgCount) { fireOsNotif('๐Ÿ’ฌ New message!', 'Your parent sent you a message.', () => switchTab('inbox')); if (currentTab !== 'inbox') { showMsgNotifToast(); updateInboxBadge(studentId, count); } } _lastParentMsgCount = count; } catch { /* non-critical */ } }, 30000); } function stopMsgPoll() { clearInterval(_msgPollInterval); _msgPollInterval = null; _lastParentMsgCount = -1; } async function openKid(id, tab = 'today', welcome = false) { 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`), ]); refreshInboxBadge(id); requestNotifPermission(); startMsgPoll(id); currentStudent = state.student; youngLearnerMode = !!currentStudent.young_learner; applyTheme(currentStudent.theme || 'blue'); applyBgPattern(currentStudent.bg_pattern || 'none'); 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'); currentTab = tab; switchTab(tab); if (welcome) showWelcomeToast(currentStudent.emoji, currentStudent.name); // Birthday celebration โ€” show once per day when it's the student's birthday week if (state.student.isBirthdayWeek) showBirthdayCelebration(state.student.name); // 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 }) { playFanfare(); 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; }); // ---------- birthday celebration ---------- function showBirthdayCelebration(name) { const key = `bday_shown_${name}_${todayStr()}`; if (sessionStorage.getItem(key)) return; // only once per session per day sessionStorage.setItem(key, '1'); $('#birthday-heading').textContent = `Happy Birthday, ${name}! ๐ŸŽ‰`; $('#birthday-message').textContent = 'Wishing you an amazing day full of fun and joy!'; $('#birthday-overlay').hidden = false; playFanfare(); popConfetti(); } $('#birthday-close').addEventListener('click', () => { $('#birthday-overlay').hidden = true; }); // ---------- mini celebration (confetti + toast) ---------- let pendingMiniCelebration = false; function popConfetti() { const colors = ['#4f86f7','#e84040','#2e9e5b','#e8802a','#7c5cbf','#d45d8a','#c8960c','#20a8a0','#f7c948','#ff6b6b','#48dbfb']; const container = document.createElement('div'); container.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:999;overflow:hidden'; document.body.appendChild(container); for (let i = 0; i < 160; i++) { const piece = document.createElement('div'); const color = colors[Math.floor(Math.random() * colors.length)]; const x = Math.random() * 110 - 5; const delay = Math.random() * 1.2; const dur = 1.8 + Math.random() * 1.4; const size = 10 + Math.random() * 18; const shape = Math.random(); let borderRadius = '2px'; if (shape > 0.66) borderRadius = '50%'; else if (shape > 0.33) borderRadius = '0'; piece.style.cssText = `position:absolute;left:${x}%;top:-30px;width:${size}px;height:${size * (0.4 + Math.random() * 0.8)}px;` + `background:${color};border-radius:${borderRadius};` + `animation:confetti-fall ${dur}s ${delay}s ease-in forwards;transform:rotate(${Math.random()*360}deg)`; container.appendChild(piece); } setTimeout(() => container.remove(), 4000); } function playFlip() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const t = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.setValueAtTime(320, t); osc.frequency.exponentialRampToValueAtTime(680, t + 0.05); osc.frequency.exponentialRampToValueAtTime(220, t + 0.14); gain.gain.setValueAtTime(0.16, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.16); osc.start(t); osc.stop(t + 0.16); setTimeout(() => ctx.close(), 400); } catch(e) {} } function playDing() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const t = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.setValueAtTime(1400, t); osc.frequency.exponentialRampToValueAtTime(900, t + 0.12); gain.gain.setValueAtTime(0.28, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); osc.start(t); osc.stop(t + 0.35); setTimeout(() => ctx.close(), 600); } catch(e) {} } function playBuzz() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const t = ctx.currentTime; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(220, t); osc.frequency.exponentialRampToValueAtTime(80, t + 0.18); gain.gain.setValueAtTime(0.22, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.22); osc.start(t); osc.stop(t + 0.22); setTimeout(() => ctx.close(), 400); } catch(e) {} } function playFanfare() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const t = ctx.currentTime; // Rising arpeggio [[523, 0], [659, 0.13], [784, 0.26], [1047, 0.39]].forEach(([freq, delay]) => { const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.connect(g); g.connect(ctx.destination); osc.frequency.value = freq; const s = t + delay; g.gain.setValueAtTime(0.38, s); g.gain.exponentialRampToValueAtTime(0.001, s + 0.28); osc.start(s); osc.stop(s + 0.28); }); // Final sustained chord [523, 659, 784, 1047].forEach((freq) => { const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.connect(g); g.connect(ctx.destination); osc.frequency.value = freq; const s = t + 0.55; g.gain.setValueAtTime(0.22, s); g.gain.exponentialRampToValueAtTime(0.001, s + 1.1); osc.start(s); osc.stop(s + 1.15); }); setTimeout(() => ctx.close(), 2500); } catch(e) {} } function animateScore(el, score, total) { if (!el) return; const duration = 650; const start = performance.now(); const tick = (now) => { const p = Math.min((now - start) / duration, 1); const eased = 1 - Math.pow(1 - p, 3); el.textContent = `${Math.round(eased * score)} / ${total}`; if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } function playPopSound() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const t = ctx.currentTime; // Thump: short pitch-sweep down like a party popper const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.setValueAtTime(900, t); osc.frequency.exponentialRampToValueAtTime(80, t + 0.13); gain.gain.setValueAtTime(0.55, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.13); osc.start(t); osc.stop(t + 0.13); // Sparkle: quick ascending arpeggio C-E-G-C [523, 659, 784, 1047].forEach((freq, i) => { const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.type = 'sine'; o.frequency.value = freq; const s = t + 0.06 + i * 0.075; g.gain.setValueAtTime(0, s); g.gain.linearRampToValueAtTime(0.28, s + 0.02); g.gain.exponentialRampToValueAtTime(0.001, s + 0.13); o.start(s); o.stop(s + 0.15); }); setTimeout(() => ctx.close(), 1200); } catch (e) {} } const CHEERS = [ '๐ŸŽ‰ Nailed it!', 'โญ Star student!', '๐Ÿ’ช You\'re crushing it!', '๐Ÿ”ฅ On fire!', '๐ŸŽฏ Spot on!', 'โœจ Brilliant!', '๐Ÿš€ Killing it!', '๐Ÿ… Awesome work!', '๐Ÿ’ซ You rock!', '๐Ÿฆพ Unstoppable!', ]; function randomCheer() { return CHEERS[Math.floor(Math.random() * CHEERS.length)]; } function showMiniCelebration(msg = '๐ŸŽ‰ Great job!') { playPopSound(); popConfetti(); const toast = document.createElement('div'); toast.className = 'mini-celebration-toast'; toast.textContent = msg; document.body.appendChild(toast); setTimeout(() => { toast.style.animation = 'mini-toast-out 0.5s ease forwards'; }, 1800); setTimeout(() => toast.remove(), 2400); } function showWelcomeToast(emoji, name) { const toast = document.createElement('div'); toast.className = 'welcome-toast'; toast.innerHTML = `${emoji}
Hi, ${esc(name)}! ๐Ÿ‘‹
`; document.body.appendChild(toast); setTimeout(() => toast.classList.add('welcome-toast-out'), 1700); setTimeout(() => toast.remove(), 2150); } function showDoneStamp() { return new Promise((resolve) => { const wrap = document.createElement('div'); wrap.className = 'done-stamp-wrap'; const stamp = document.createElement('div'); stamp.className = 'done-stamp'; stamp.textContent = 'โœ…'; wrap.appendChild(stamp); document.body.appendChild(wrap); setTimeout(() => { stamp.classList.add('done-stamp-out'); setTimeout(() => { wrap.remove(); resolve(); }, 380); }, 750); }); } // Theme picker toggle $('#theme-picker-btn').addEventListener('click', () => { $('#theme-dots').hidden = !$('#theme-dots').hidden; $('#icon-grid').hidden = true; $('#bg-pattern-panel').hidden = true; }); // BG pattern picker toggle $('#bg-picker-btn').addEventListener('click', () => { $('#bg-pattern-panel').hidden = !$('#bg-pattern-panel').hidden; $('#theme-dots').hidden = true; $('#icon-grid').hidden = true; }); document.querySelectorAll('.bg-pattern-btn').forEach(btn => btn.addEventListener('click', async () => { const pattern = btn.dataset.pattern; applyBgPattern(pattern); currentStudent.bg_pattern = pattern; await api(`/api/students/${currentStudent.id}/bg-pattern`, { method: 'PATCH', body: { pattern } }); $('#bg-pattern-panel').hidden = true; }) ); 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; }) ); // ---------- animal / icon picker ---------- $('#icon-picker-btn').addEventListener('click', () => { const grid = $('#icon-grid'); if (grid.hidden) { grid.innerHTML = ANIMAL_EMOJIS.map((e) => `` ).join(''); grid.querySelectorAll('.icon-choice').forEach((btn) => btn.addEventListener('click', async () => { const emoji = btn.dataset.emoji; currentStudent.emoji = emoji; $('#kid-greeting').textContent = `${emoji} Hi, ${currentStudent.name}!`; grid.querySelectorAll('.icon-choice').forEach((b) => b.classList.toggle('active', b.dataset.emoji === emoji)); await api(`/api/students/${currentStudent.id}/emoji`, { method: 'PATCH', body: { emoji } }); grid.hidden = true; }) ); } grid.hidden = !grid.hidden; $('#theme-dots').hidden = true; $('#bg-pattern-panel').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, tests = []) { 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; } $('#kid-week').innerHTML = html; $('#btn-test').disabled = !state.assignment; $('#btn-print-list').disabled = !state.assignment; const histEl = $('#kid-test-history'); if (!tests.length) { histEl.innerHTML = ''; } else { const rows = tests.map((t) => { const pct = Math.round((t.score / t.total) * 100); return `
${new Date(t.at + 'Z').toLocaleDateString()} ${esc(t.list)} ${t.score}/${t.total} (${pct}%)
`; }).join(''); histEl.innerHTML = `

Recent Tests

${rows}`; } } $('#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 practiceRows = words.map((w, i) => { const sentence = w.sentence ? `
${esc(w.sentence)}
` : ''; return ` ${i + 1}. ${esc(w.word)}${sentence}
`; }).join(''); const dictRows = words.map((w, i) => `
${i + 1}.
` ).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.

${practiceRows}
WordPracticeFrom memory

Write each spelling word as it is called out.

${dictRows}