// ---------- 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(); }) ); // ---------- text to speech (spelling module) ---------- function speak(text, rate = 0.85) { const u = new SpeechSynthesisUtterance(text); u.rate = rate; u.lang = 'en-US'; speechSynthesis.speak(u); } function speakWord(w) { speechSynthesis.cancel(); speak(w.word); if (w.sentence) speak(w.sentence, 0.95); } // ---------- 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('') }; } // ---------- home (kid picker) ---------- let currentStudent = null; async function loadHome() { 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; $('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`; renderToday(agenda.tasks); renderCourseCards(courses); renderSpellingTab(state); show('kid'); switchTab(tab); } function renderToday(tasks) { $('#today-empty').hidden = tasks.length > 0; $('#today-list').innerHTML = tasks .map((t) => { if (!t.itemId) { return ``; } const badge = statusBadge(t.type, t.subStatus, t.score, t.points_possible, t.done); return ``; }) .join(''); document.querySelectorAll('.today-row.offline').forEach((row) => row.addEventListener('click', async () => { const done = row.querySelector('.row-check').textContent.trim() !== 'βœ…'; await api(`/api/schedule/${row.dataset.scheduleId}/done`, { method: 'POST', body: { done } }); openKid(currentStudent.id, 'today'); }) ); 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))) ); } 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
`; } else { html = `No list assigned yet β€” ask a parent to pick one!`; } 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; } // ---------- course detail ---------- async function openCourse(courseId) { const course = await api(`/api/courses/${courseId}/detail?studentId=${currentStudent.id}`); $('#course-title').textContent = course.name; $('#course-units').innerHTML = course.units .map( (u) => `

${esc(u.name)}

${u.items .map((it) => { const badge = statusBadge(it.type, it.status, it.score, it.points_possible, it.status !== 'not_started'); return ``; }) .join('')}
` ) .join(''); document.querySelectorAll('#course-units .today-row').forEach((row) => row.addEventListener('click', () => { backTarget = () => openCourse(courseId); openItem(Number(row.dataset.itemId), row.dataset.itemType); }) ); show('course'); } // ---------- item dispatch ---------- async function openItem(itemId, type) { if (type === 'lesson') return openLesson(itemId); if (type === 'assignment') return openAssignment(itemId); if (type === 'quiz') return openQuiz(itemId); const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId }); if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId }); if (type === 'flashcards') return startFlashcards({ deckId: item.deck.id, deckName: item.deck.name, itemId }); } // ---------- lesson ---------- async function openLesson(itemId) { const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); $('#lesson-kicker').textContent = `${item.course_name} Β· ${item.unit_name}`; $('#lesson-title').textContent = item.title; $('#lesson-body').innerHTML = item.body; show('lesson'); const btn = $('#lesson-done-btn'); btn.textContent = item.submission ? 'βœ… Read' : 'Done reading βœ“'; btn.onclick = async () => { await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); backTarget(); }; } // ---------- assignment ---------- async function openAssignment(itemId) { const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); $('#assignment-kicker').textContent = `${item.course_name} Β· ${item.unit_name}`; $('#assignment-title').textContent = item.title; $('#assignment-points').textContent = item.points ? `Worth ${item.points} points` : ''; $('#assignment-body').innerHTML = esc(item.body).replace(/\n/g, '
'); const btn = $('#assignment-done-btn'); const status = $('#assignment-status'); if (item.submission && item.submission.status === 'graded') { btn.hidden = true; status.hidden = false; status.className = 'status-banner good'; status.textContent = `🌟 Graded: ${item.submission.score} / ${item.submission.points_possible}`; } else if (item.submission && item.submission.status === 'done') { btn.hidden = true; status.hidden = false; status.className = 'status-banner'; status.textContent = `⏳ Turned in β€” waiting for a parent to grade it.`; } else { btn.hidden = false; status.hidden = true; btn.onclick = async () => { await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); openAssignment(itemId); }; } show('assignment'); } // ---------- quiz ---------- async function openQuiz(itemId) { const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); $('#quiz-kicker').textContent = `${item.course_name} Β· ${item.unit_name}`; $('#quiz-title').textContent = item.title; const graded = item.submission && item.submission.status === 'graded'; const result = $('#quiz-result'); if (graded) { result.hidden = false; result.className = 'status-banner good'; result.textContent = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible}`; $('#quiz-form').innerHTML = item.questions .map((q) => { const correct = normalizeAnswer(q.given) === normalizeAnswer(q.correct_answer); return `

${correct ? 'βœ…' : '❌'} ${esc(q.prompt)}

Your answer: ${esc(q.given || '(blank)')}

${correct ? '' : `

Correct answer: ${esc(q.correct_answer)}

`}
`; }) .join(''); } else { result.hidden = true; $('#quiz-form').innerHTML = item.questions .map((q, i) => { if (q.type === 'mc') { return `

${i + 1}. ${esc(q.prompt)}

${q.choices .map( (c) => `` ) .join('')}
`; } if (q.type === 'tf') { return `

${i + 1}. ${esc(q.prompt)}

`; } return `

${i + 1}. ${esc(q.prompt)}

`; }) .join('') + ``; $('#quiz-form').onsubmit = async (e) => { e.preventDefault(); const submitBtn = $('#quiz-form button[type=submit]'); if (submitBtn.disabled) return; submitBtn.disabled = true; const answers = {}; for (const q of item.questions) { const field = $('#quiz-form').elements[`q${q.id}`]; answers[q.id] = field ? (field.value !== undefined ? field.value : '') : ''; } await api(`/api/items/${itemId}/quiz-submit`, { method: 'POST', body: { studentId: currentStudent.id, answers, date: todayStr() }, }); openQuiz(itemId); }; } show('quiz'); } const normalizeAnswer = (s) => String(s ?? '').trim().toLowerCase(); // ---------- flashcards ---------- const flash = { cards: [], i: 0, itemId: null, reviewed: 0 }; async function startFlashcards({ deckId, deckName, itemId }) { const { cards } = await api(`/api/flashcards/session/${currentStudent.id}?deckId=${deckId}`); Object.assign(flash, { cards, i: 0, itemId: itemId || null, reviewed: 0 }); $('#flash-deck-name').textContent = deckName || ''; $('#flash-card').hidden = false; $('#flash-done').hidden = true; show('flashcards'); if (cards.length === 0) return finishFlashcards(true); presentFlashcard(); } function presentFlashcard() { flashGrading = false; const c = flash.cards[flash.i]; $('#flash-progress').textContent = `Card ${flash.i + 1} of ${flash.cards.length}`; $('#flash-bar').style.width = `${(flash.i / flash.cards.length) * 100}%`; $('#flash-front').textContent = c.front; $('#flash-front').hidden = false; $('#flash-back').hidden = true; $('#flash-back').textContent = c.back; $('#flash-reveal').hidden = false; $('#flash-grade-buttons').hidden = true; } $('#flash-reveal').addEventListener('click', () => { $('#flash-back').hidden = false; $('#flash-reveal').hidden = true; $('#flash-grade-buttons').hidden = false; }); let flashGrading = false; async function gradeFlashcard(gotIt) { if (flashGrading) return; // guard against a rapid double-tap misgrading two cards flashGrading = true; const c = flash.cards[flash.i]; await api('/api/flashcards/grade', { method: 'POST', body: { studentId: currentStudent.id, cardId: c.id, gotIt } }); flash.reviewed++; flash.i++; if (flash.i < flash.cards.length) return presentFlashcard(); finishFlashcards(false); } $('#flash-again').addEventListener('click', () => gradeFlashcard(false)); $('#flash-got-it').addEventListener('click', () => gradeFlashcard(true)); async function finishFlashcards(nothingDue) { $('#flash-bar').style.width = '100%'; $('#flash-progress').textContent = 'Done!'; $('#flash-card').hidden = true; if (flash.itemId) { await api(`/api/items/${flash.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); } $('#flash-done').innerHTML = nothingDue ? `
πŸŽ‰ Nothing due on this deck right now!
` : `
πŸŽ‰ Reviewed ${flash.reviewed} card${flash.reviewed === 1 ? '' : 's'}!
`; $('#flash-done').hidden = false; $('#flash-home').addEventListener('click', () => backTarget()); } // ---------- spelling: practice ---------- const practice = { words: [], i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: null }; async function startPractice({ listId, itemId } = {}) { const url = listId ? `/api/session/${currentStudent.id}?listId=${listId}` : `/api/session/${currentStudent.id}`; const { words } = await api(url); if (words.length === 0) { $('#practice-card').hidden = true; $('#practice-done').hidden = false; $('#practice-done').innerHTML = `
πŸŽ‰ Nothing to practice right now β€” all caught up!
`; $('#practice-home').addEventListener('click', () => backTarget()); show('practice'); return; } Object.assign(practice, { words, i: 0, missed: [], firstTryCorrect: 0, awaitingRetype: false, itemId: itemId || null }); $('#practice-card').hidden = false; $('#practice-done').hidden = true; show('practice'); presentPracticeWord(); } function presentPracticeWord() { const w = practice.words[practice.i]; practice.awaitingRetype = false; $('#practice-progress').textContent = `Word ${practice.i + 1} of ${practice.words.length}`; $('#practice-bar').style.width = `${(practice.i / practice.words.length) * 100}%`; $('#practice-prompt').textContent = 'Listen, then type the word:'; $('#feedback').innerHTML = ''; const input = $('#practice-input'); input.value = ''; input.disabled = false; input.focus(); speakWord(w); } $('#btn-speak').addEventListener('click', () => speakWord(practice.words[practice.i])); $('#practice-form').addEventListener('submit', async (e) => { e.preventDefault(); const input = $('#practice-input'); const typed = input.value.trim(); if (!typed || input.disabled) return; const w = practice.words[practice.i]; const result = await api('/api/attempt', { method: 'POST', body: { studentId: currentStudent.id, wordId: w.id, typed, firstTry: !practice.awaitingRetype }, }); if (result.correct) { if (!practice.awaitingRetype) practice.firstTryCorrect++; $('#feedback').innerHTML = ``; input.disabled = true; setTimeout(nextPracticeWord, 900); } else { if (!practice.awaitingRetype) practice.missed.push(w.word); showStudyThenRetype(typed, w); } }); // look–cover–write–check: show the diff and the correct word, then hide it and retype function showStudyThenRetype(typed, w) { practice.awaitingRetype = true; const input = $('#practice-input'); input.disabled = true; const d = diffLetters(typed, w.word); const fb = $('#feedback'); let secs = 4; const render = () => { fb.innerHTML = `
You typed${d.typedHtml}
Correct${d.correctHtml}
${esc(w.word)}
Memorize it… ${secs}
`; }; render(); const timer = setInterval(() => { secs--; if (secs > 0) return render(); clearInterval(timer); fb.innerHTML = ``; $('#practice-prompt').textContent = 'Type it from memory:'; input.value = ''; input.disabled = false; input.focus(); }, 1000); } async function nextPracticeWord() { practice.i++; if (practice.i < practice.words.length) return presentPracticeWord(); $('#practice-bar').style.width = '100%'; $('#practice-progress').textContent = 'Done!'; $('#practice-card').hidden = true; if (practice.itemId) { await api(`/api/items/${practice.itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); } const missedHtml = practice.missed.length ? `

Words to keep working on:

` : `

Perfect round β€” every word right on the first try! πŸ†

`; $('#practice-done').innerHTML = `
πŸŽ‰ Practice complete!
${practice.firstTryCorrect} / ${practice.words.length}

right on the first try

${missedHtml} `; $('#practice-done').hidden = false; $('#practice-home').addEventListener('click', () => backTarget()); } // ---------- spelling: test ---------- const test = { list: null, words: [], i: 0, answers: [], itemId: null }; async function startTest({ listId, itemId } = {}) { const url = listId ? `/api/test/${currentStudent.id}?listId=${listId}` : `/api/test/${currentStudent.id}`; const data = await api(url); Object.assign(test, { list: data.list, words: data.words, i: 0, answers: [], itemId: itemId || null }); $('#test-card').hidden = false; $('#test-done').hidden = true; show('test'); presentTestWord(); } function presentTestWord() { const w = test.words[test.i]; $('#test-progress').textContent = `Word ${test.i + 1} of ${test.words.length}`; $('#test-bar').style.width = `${(test.i / test.words.length) * 100}%`; const input = $('#test-input'); input.value = ''; input.focus(); speakWord(w); } $('#btn-test-speak').addEventListener('click', () => speakWord(test.words[test.i])); $('#test-form').addEventListener('submit', async (e) => { e.preventDefault(); const typed = $('#test-input').value.trim(); if (!typed) return; test.answers.push({ wordId: test.words[test.i].id, typed }); test.i++; if (test.i < test.words.length) return presentTestWord(); await finishTest(); }); async function finishTest() { speechSynthesis.cancel(); const result = await api(`/api/test/${currentStudent.id}`, { method: 'POST', body: { listId: test.list.id, answers: test.answers, itemId: test.itemId, date: todayStr() }, }); $('#test-bar').style.width = '100%'; $('#test-progress').textContent = 'Test finished!'; $('#test-card').hidden = true; const rows = result.graded .map((g) => g.correct ? `
  • βœ… ${esc(g.word)}
  • ` : `
  • ❌ ${esc(g.word)} (you wrote "${esc(g.typed)}")
  • ` ) .join(''); $('#test-done').innerHTML = `
    ⭐ Test complete!
    ${result.score} / ${result.total}
    `; $('#test-done').hidden = false; $('#test-home').addEventListener('click', () => backTarget()); } $('#btn-practice').addEventListener('click', () => { backTarget = () => openKid(currentStudent.id, 'spelling'); startPractice(); }); $('#btn-test').addEventListener('click', () => { backTarget = () => openKid(currentStudent.id, 'spelling'); startTest(); }); // ---------- boot ---------- loadHome();