/* ============================================ ECHO TUTOR PRO — Phase 3 Features Module Theme, Gamification, Exercises, PDF, Whisper ============================================ */ window.EchoFeatures = (function () { const $ = (id) => document.getElementById(id); // ─── THEME TOGGLE ─── const Theme = { init() { const saved = localStorage.getItem('echo_theme') || 'dark'; document.documentElement.setAttribute('data-theme', saved); const btn = $('theme-btn'); if (btn) btn.addEventListener('click', () => this.toggle()); }, toggle() { const current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('echo_theme', next); }, }; // ─── XP & GAMIFICATION ─── const XP = { data: { totalXP: 0, streak: 0, lastDay: '', messages: 0, corrections: 0, vocab: 0, exercises: 0, perfect_pronun: 0, badges: [], }, load() { try { const saved = localStorage.getItem('echo_xp'); if (saved) this.data = { ...this.data, ...JSON.parse(saved) }; this.updateStreak(); this.render(); } catch (e) {} }, save() { try { localStorage.setItem('echo_xp', JSON.stringify(this.data)); } catch (e) {} }, add(amount, reason) { this.data.totalXP += amount; this.save(); this.render(); this.showPopup(amount, reason); this.checkBadges(); }, getLevel() { return Math.floor(this.data.totalXP / 100) + 1; }, getLevelProgress() { return this.data.totalXP % 100; }, updateStreak() { const today = new Date().toISOString().slice(0, 10); if (this.data.lastDay === today) return; const yesterday = new Date(Date.now() - 86400000) .toISOString() .slice(0, 10); if (this.data.lastDay === yesterday) { this.data.streak++; } else if (this.data.lastDay !== today) { this.data.streak = 1; } this.data.lastDay = today; this.save(); }, render() { const lvl = this.getLevel(); const prog = this.getLevelProgress(); const el = (id, val) => { const e = $(id); if (e) e.textContent = val; }; el('xp-level', `Lv.${lvl}`); el('xp-value', `${this.data.totalXP} XP`); el('streak-count', `🔥 ${this.data.streak}`); const fill = $('xp-bar-fill'); if (fill) fill.style.width = prog + '%'; }, showPopup(amount, reason) { const popup = $('xp-popup'); if (!popup) return; popup.textContent = `+${amount} XP${reason ? ' · ' + reason : ''}`; popup.classList.remove('show'); void popup.offsetWidth; popup.classList.add('show'); setTimeout(() => popup.classList.remove('show'), 1600); }, trackMessage() { this.data.messages++; this.add(5, 'Message'); this.save(); }, trackCorrection() { this.data.corrections++; this.add(3, 'Learning'); this.save(); }, trackVocab() { this.data.vocab++; this.add(2, 'New Word'); this.save(); }, trackExercise(correct) { this.data.exercises++; this.add(correct ? 10 : 2, correct ? 'Correct!' : 'Practice'); this.save(); }, trackPronunciation(score) { if (score >= 10) { this.data.perfect_pronun++; this.add(15, 'Perfect!'); } this.save(); }, checkBadges() { const d = this.data; const checks = { first_chat: d.messages >= 1, ten_messages: d.messages >= 10, fifty_messages: d.messages >= 50, first_correction: d.corrections >= 1, vocab_10: d.vocab >= 10, streak_3: d.streak >= 3, streak_7: d.streak >= 7, streak_30: d.streak >= 30, exercise_10: d.exercises >= 10, level_5: d.totalXP >= 500, level_10: d.totalXP >= 1000, perfect_pronun: d.perfect_pronun >= 1, }; let newBadge = false; Object.entries(checks).forEach(([id, met]) => { if (met && !d.badges.includes(id)) { d.badges.push(id); newBadge = true; } }); if (newBadge) this.save(); return newBadge; }, renderBadges(containerId, allBadges) { const grid = $(containerId); if (!grid || !allBadges) return; grid.innerHTML = ''; allBadges.forEach((b) => { const earned = this.data.badges.includes(b.id); grid.innerHTML += `
${b.icon}${b.label}${b.description}
`; }); }, getSessionXP() { return this.data.totalXP; }, }; // ─── EXERCISES ENGINE ─── const Exercises = { pool: [], current: null, index: 0, score: 0, total: 0, type: 'fill_blank', async load(level, type) { this.type = type; if (type === 'ai_generated') return this.loadAI(level); try { const res = await fetch( `/exercises?level=${level}&type=${type}&count=10`, ); const data = await res.json(); this.pool = data.exercises || []; this.index = 0; this.score = 0; this.total = this.pool.length; if (this.pool.length) this.show(); else this.showEmpty(); } catch (e) { this.showEmpty(); } }, async loadAI(level) { const corrections = JSON.parse( localStorage.getItem('echo_corrections') || '[]', ); if (!corrections.length) { this.showEmpty('Chat first to get personalized exercises!'); return; } $('exercise-question').textContent = 'Generating exercises from your mistakes...'; try { const res = await fetch('/exercises/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ errors: corrections.slice(-10), level }), }); const data = await res.json(); this.pool = (data.exercises || []).map((e, i) => ({ ...e, id: 'ai_' + i, type: 'fill_blank', })); this.index = 0; this.score = 0; this.total = this.pool.length; if (this.pool.length) this.show(); else this.showEmpty('Could not generate exercises.'); } catch (e) { this.showEmpty('Error generating exercises.'); } }, show() { if (this.index >= this.pool.length) { this.showComplete(); return; } this.current = this.pool[this.index]; const q = $('exercise-question'); const opts = $('exercise-options'); const result = $('exercise-result'); const explain = $('exercise-explain'); const inputArea = $('exercise-input-area'); if (result) { result.classList.add('hidden'); result.className = 'exercise-result hidden'; } if (explain) explain.classList.add('hidden'); if (inputArea) inputArea.classList.add('hidden'); $('exercise-progress').textContent = `${this.index + 1}/${this.total}`; $('exercise-score').textContent = `Score: ${this.score}/${this.index}`; const ex = this.current; if (!ex) return; // Determine exercise type from data const exType = ex.type || this.type; if (exType === 'sentence_reorder' || (ex.words && !ex.options)) { this.showReorder(ex); } else if (exType === 'translation') { this.showTranslation(ex); } else { // fill_blank or word_match — multiple choice if (q) q.innerHTML = ex.q || ex.word || ''; if (ex.type === 'synonym') q.innerHTML += '
Find the synonym'; if (ex.type === 'antonym') q.innerHTML += '
Find the antonym'; opts.innerHTML = ''; opts.classList.remove('hidden'); (ex.options || []).forEach((opt) => { const btn = document.createElement('button'); btn.className = 'exercise-option'; btn.textContent = opt; btn.addEventListener('click', () => this.checkAnswer(opt, ex.answer, btn), ); opts.appendChild(btn); }); } }, showReorder(ex) { const q = $('exercise-question'); const opts = $('exercise-options'); q.innerHTML = 'Arrange the words in correct order:'; opts.innerHTML = ''; const words = [...ex.words].sort(() => Math.random() - 0.5); const selected = []; const answerArea = document.createElement('div'); answerArea.className = 'reorder-area'; answerArea.innerHTML = 'Click words to build the sentence'; opts.appendChild(answerArea); const wordPool = document.createElement('div'); wordPool.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-top:10px'; words.forEach((w) => { const chip = document.createElement('span'); chip.className = 'reorder-word'; chip.textContent = w; chip.addEventListener('click', () => { if (chip.classList.contains('placed')) { chip.classList.remove('placed'); const idx = selected.indexOf(w); if (idx >= 0) selected.splice(idx, 1); } else { chip.classList.add('placed'); selected.push(w); } answerArea.innerHTML = selected.join(' ') || 'Click words'; if (selected.length === words.length) { const userAns = selected.join(' '); const correct = userAns.toLowerCase() === ex.answer.toLowerCase(); if (correct) this.score++; this.showResult(correct, ex.answer); } }); wordPool.appendChild(chip); }); opts.appendChild(wordPool); }, showTranslation(ex) { const q = $('exercise-question'); const opts = $('exercise-options'); const inputArea = $('exercise-input-area'); q.innerHTML = `Translate to ${ex.target_lang === 'en' ? 'English' : ex.target_lang}:

"${ex.source}"`; opts.innerHTML = ''; if (inputArea) { inputArea.classList.remove('hidden'); const inp = $('exercise-input'); const checkBtn = $('exercise-check'); if (inp) inp.value = ''; if (checkBtn) { const handler = () => { const userAns = (inp?.value || '') .trim() .toLowerCase() .replace(/[?.!,]/g, ''); const correct = [ex.answer, ...(ex.alternatives || [])].some( (a) => userAns === a.toLowerCase().replace(/[?.!,]/g, ''), ); if (correct) this.score++; this.showResult(correct, ex.answer); checkBtn.removeEventListener('click', handler); }; checkBtn.addEventListener('click', handler); } } }, checkAnswer(selected, correct, btn) { const opts = $('exercise-options'); const isCorrect = selected === correct; if (isCorrect) this.score++; btn.classList.add(isCorrect ? 'correct' : 'wrong'); opts.querySelectorAll('.exercise-option').forEach((b) => { b.classList.add('disabled'); if (b.textContent === correct) b.classList.add('correct'); }); this.showResult(isCorrect, correct); }, showResult(correct, answer) { const result = $('exercise-result'); const explain = $('exercise-explain'); if (result) { result.classList.remove('hidden'); result.className = `exercise-result ${correct ? 'correct' : 'wrong'}`; result.textContent = correct ? 'Correct! Well done!' : `Incorrect. Answer: ${answer}`; } if (explain && this.current?.explain) { explain.classList.remove('hidden'); explain.textContent = this.current.explain; } XP.trackExercise(correct); $('exercise-score').textContent = `Score: ${this.score}/${this.index + 1}`; }, next() { this.index++; if (this.index >= this.pool.length) this.showComplete(); else this.show(); }, showComplete() { const q = $('exercise-question'); const opts = $('exercise-options'); if (q) q.innerHTML = `

Exercise Complete!

Score: ${this.score}/${this.total}

${this.score === this.total ? 'Perfect score! Amazing!' : this.score >= this.total * 0.7 ? 'Great job!' : 'Keep practicing!'}

`; if (opts) opts.innerHTML = ''; }, showEmpty(msg) { const q = $('exercise-question'); if (q) q.textContent = msg || 'No exercises available for this type/level.'; $('exercise-options').innerHTML = ''; }, }; // ─── PDF EXPORT ─── const PDF = { export(sessionData) { const { duration, messages, corrections, vocab, avgPronun, xp } = sessionData; const lines = [ 'ECHO TUTOR PRO — Session Report', '═'.repeat(40), `Date: ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}`, `Duration: ${duration}`, `Messages: ${messages}`, `Corrections: ${corrections}`, `New Words: ${vocab}`, `Avg Pronunciation: ${avgPronun}`, `XP Earned: +${xp}`, '', '─'.repeat(40), 'CONVERSATION:', '─'.repeat(40), '', ]; const msgEls = document.querySelectorAll('#messages .message'); msgEls.forEach((m) => { const role = m.classList.contains('user') ? 'YOU' : 'ECHO'; lines.push(`[${role}] ${m.textContent.trim()}`); lines.push(''); }); if (corrections > 0) { lines.push('─'.repeat(40), 'CORRECTIONS:', '─'.repeat(40), ''); const stored = JSON.parse( localStorage.getItem('echo_corrections') || '[]', ); stored.forEach((c) => lines.push(`✗ ${c.wrong} → ✓ ${c.right}`)); } const text = lines.join('\n'); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `echo-session-${new Date().toISOString().slice(0, 10)}.txt`; a.click(); URL.revokeObjectURL(url); }, }; // ─── WHISPER STT ─── const Whisper = { recorder: null, chunks: [], recording: false, mimeType: 'audio/webm', fileExt: 'webm', pickMimeType() { try { if (!window.MediaRecorder?.isTypeSupported) return ''; const candidates = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/mpeg', ]; for (const mimeType of candidates) { if (window.MediaRecorder.isTypeSupported(mimeType)) { return mimeType; } } } catch (e) {} return ''; }, async start(lang, onSilence) { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); const mimeType = this.pickMimeType(); this.mimeType = mimeType || 'audio/webm'; this.fileExt = this.mimeType.includes('mp4') ? 'm4a' : this.mimeType.includes('mpeg') ? 'mp3' : 'webm'; this.recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); this.chunks = []; this.recorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); }; this.recorder.start(250); this.recording = true; // Simple VAD (Voice Activity Detection) for auto-stop if (onSilence) { try { const audioCtx = new ( window.AudioContext || window.webkitAudioContext )(); const source = audioCtx.createMediaStreamSource(stream); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 512; analyser.smoothingTimeConstant = 0.1; source.connect(analyser); const dataArray = new Uint8Array(analyser.frequencyBinCount); let silenceStart = Date.now(); let hasSpoken = false; const checkSilence = () => { if (!this.recording) { audioCtx.close().catch(() => {}); return; } analyser.getByteFrequencyData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i++) { sum += dataArray[i]; } const average = sum / dataArray.length; if (average > 15) { // Threshold for speech hasSpoken = true; silenceStart = Date.now(); } else { if (hasSpoken && Date.now() - silenceStart > 2000) { // 2s silence after speech onSilence(); audioCtx.close().catch(() => {}); return; } else if (!hasSpoken && Date.now() - silenceStart > 10000) { // 10s silence without speech onSilence(); audioCtx.close().catch(() => {}); return; } } requestAnimationFrame(checkSilence); }; checkSilence(); } catch (e) { console.warn('VAD setup failed:', e); } } return stream; } catch (e) { console.error('Whisper mic error:', e); return null; } }, stop() { return new Promise((resolve) => { if (!this.recorder || !this.recording) { resolve(null); return; } const finish = () => { this.recording = false; if (this.recorder && this.recorder.stream) { this.recorder.stream.getTracks().forEach((t) => t.stop()); } if (!this.chunks.length) { resolve(null); return; } const blob = new Blob(this.chunks, { type: this.mimeType || this.chunks[0]?.type || 'audio/webm', }); resolve(blob); }; this.recorder.onstop = finish; try { if (this.recorder.state !== 'inactive') { this.recorder.stop(); } else { finish(); } } catch (e) { console.error('Error stopping recorder:', e); finish(); } }); }, async transcribe(blob, lang) { const form = new FormData(); form.append('audio', blob, `recording.${this.fileExt || 'webm'}`); form.append('language', lang || 'en'); try { const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; const timeout = controller ? setTimeout(() => controller.abort(), 45000) : null; const res = await fetch('/transcribe', { method: 'POST', body: form, signal: controller?.signal, }); if (timeout) clearTimeout(timeout); let payload = null; try { payload = await res.json(); } catch (e) {} if (!res.ok) { return { ok: false, error: payload?.error || `Transcription request failed (${res.status})`, }; } if (!payload?.text) { return { ok: false, error: payload?.error || 'No transcript returned from Whisper', }; } return { ok: true, text: payload.text, language: payload.language || lang || 'en', }; } catch (e) { if (e?.name === 'AbortError') { return { ok: false, error: 'Transcription timed out. Try again.', }; } return { ok: false, error: 'Could not reach transcription service', }; } }, }; // ─── SCENARIO HELPERS ─── const Scenarios = { data: [], render(containerId) { const grid = $(containerId); if (!grid || !this.data.length) return; grid.innerHTML = ''; this.data.forEach((s) => { const card = document.createElement('button'); card.className = 'scenario-card'; card.dataset.scenario = s.id; card.innerHTML = `${s.icon}${s.label}${s.description}`; grid.appendChild(card); }); }, }; // ─── INIT ─── function init() { Theme.init(); XP.load(); // Exercise modal controls $('exercises-btn')?.addEventListener('click', () => { $('exercises-modal')?.classList.toggle('hidden'); Exercises.load('intermediate', 'fill_blank'); }); $('exercises-close')?.addEventListener('click', () => $('exercises-modal')?.classList.add('hidden'), ); $('exercise-next')?.addEventListener('click', () => Exercises.next()); // Exercise type tabs document.querySelectorAll('.ex-type-btn').forEach((btn) => { btn.addEventListener('click', () => { document .querySelectorAll('.ex-type-btn') .forEach((b) => b.classList.remove('active')); btn.classList.add('active'); const level = window._echoLevel || 'intermediate'; Exercises.load(level, btn.dataset.type); }); }); // XP display click -> badges $('xp-display')?.addEventListener('click', () => $('badges-modal')?.classList.toggle('hidden'), ); $('badges-close')?.addEventListener('click', () => $('badges-modal')?.classList.add('hidden'), ); // PDF export $('summary-pdf')?.addEventListener('click', () => { PDF.export({ duration: $('sum-time')?.textContent || '0:00', messages: $('sum-msgs')?.textContent || '0', corrections: $('sum-corrections')?.textContent || '0', vocab: $('sum-vocab')?.textContent || '0', avgPronun: $('sum-pronun')?.textContent || '-', xp: $('sum-xp')?.textContent || '0', }); }); } // Auto-init on DOMContentLoaded (may already have fired) if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } return { Theme, XP, Exercises, PDF, Whisper, Scenarios }; })();