// ---------- 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.courseName)} ยท ${esc(r.unitName)} ยท ${date}
${r.points_possible !== null ? `
๐ ${r.score} / ${r.points_possible}${pct !== null ? ` (${pct}%)` : ''}
` : ''}
${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 ``;
}).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) =>
``
).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.
| Word | Practice | From memory |
${practiceRows}
Write each spelling word as it is called out.
${dictRows}