english-trainer/app.js

670 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function(){
"use strict";
var CUR = window.CURRICULUM || [];
var N = CUR.length;
var SECTIONS = [
{id:"words", icon:"📖", label:"Слова"},
{id:"dialogue", icon:"💬", label:"Диалог"},
{id:"reading", icon:"📰", label:"Чтение"},
{id:"listening", icon:"🎧", label:"Аудио"},
{id:"speaking", icon:"🎤", label:"Говорю"},
{id:"writing", icon:"✍️", label:"Пишу"}
];
var ACTIVE = "words";
var curIdx = 0;
var todayContentIdx = 0;
var dayKey = 0;
var state = null;
/* ---------- storage ---------- */
function todayStr(){ var d=new Date(); return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); }
function dateStr(d){ return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); }
function load(){
try{ state = JSON.parse(localStorage.getItem("enTrainer")||"null"); }catch(e){ state=null; }
var today = todayStr();
if(!state){ state={startDate:today, streak:1, lastVisit:today, completed:{}, wordsLearned:[], reminderOn:false, reminderTime:"20:00", lastNotified:null, tests:{}}; }
if(state.reminderOn===undefined) state.reminderOn=false;
if(!state.reminderTime) state.reminderTime="20:00";
if(state.lastNotified===undefined) state.lastNotified=null;
if(!state.tests) state.tests={};
// streak
if(state.lastVisit !== today){
var y=new Date(); y.setDate(y.getDate()-1);
state.streak = (state.lastVisit===dateStr(y)) ? state.streak+1 : 1;
state.lastVisit = today;
}
if(!state.completed) state.completed={};
if(!state.completed[today]) state.completed[today]=[];
if(!state.wordsLearned) state.wordsLearned=[];
save();
}
function save(){ localStorage.setItem("enTrainer", JSON.stringify(state)); }
function completedToday(){ return state.completed[todayStr()] || (state.completed[todayStr()]=[]); }
function isDone(id){ return completedToday().indexOf(id)>=0; }
function setDone(id, on){
var c=completedToday();
var i=c.indexOf(id);
if(on && i<0){ c.push(id); }
if(!on && i>=0){ c.splice(i,1); }
save();
}
/* ---------- day math ---------- */
function computeDay(){
var s=new Date(state.startDate+"T00:00:00");
var t=new Date(); t.setHours(0,0,0,0);
dayKey = Math.max(0, Math.round((t-s)/86400000));
todayContentIdx = ((dayKey % N)+N)%N;
curIdx = todayContentIdx;
}
function day(){ return CUR[curIdx]; }
/* ---------- misc helpers ---------- */
function shuffle(a){ a=a.slice(); for(var i=a.length-1;i>0;i--){ var j=Math.floor(Math.random()*(i+1)); var t=a[i]; a[i]=a[j]; a[j]=t; } return a; }
function flatten(a){ var r=[]; a.forEach(function(x){ x.forEach(function(y){ r.push(y); }); }); return r; }
function currentMonth(){ return Math.min(5, Math.floor(dayKey/30)); }
function monthLabel(m){ return "Месяц "+(m+1)+" из 6"; }
/* ---------- speech ---------- */
var voices=[];
var synth = window.speechSynthesis;
function loadVoices(){ if(synth) voices = synth.getVoices(); }
if(synth){ loadVoices(); synth.onvoiceschanged=loadVoices; }
function enVoice(pref){
var en = voices.filter(function(v){ return /en(-|_)?US/i.test(v.lang) || /en(-|_)?GB/i.test(v.lang); });
if(!en.length) en = voices.filter(function(v){ return /^en/i.test(v.lang); });
if(pref) for(var i=0;i<en.length;i++){ if(new RegExp(pref,"i").test(en[i].name)) return en[i]; }
return en[0]||null;
}
function speak(text, opts){
opts=opts||{};
if(!synth) return Promise.resolve();
return new Promise(function(res){
var u=new SpeechSynthesisUtterance(text);
u.lang="en-US";
var v=enVoice(opts.voice);
if(v) u.voice=v;
u.rate=opts.rate||1; u.pitch=opts.pitch||1;
u.onend=res; u.onerror=res;
synth.speak(u);
});
}
function stopSpeak(){ if(synth) synth.cancel(); }
/* ---------- recognition ---------- */
var REC = window.SpeechRecognition || window.webkitSpeechRecognition || null;
var currentRec=null;
function norm(s){ return (s||"").toLowerCase().replace(/[^a-z0-9\s']/g," ").replace(/\s+/g," ").trim(); }
function similarity(spoken, target){
var a=norm(spoken).split(" ").filter(Boolean);
var b=norm(target).split(" ").filter(Boolean);
if(!b.length) return 0;
var set={}; b.forEach(function(w){ set[w]=1; });
var hit=0; a.forEach(function(w){ if(set[w]){ hit++; delete set[w]; } });
return hit/b.length;
}
function levelOf(sim){ return sim>=0.8?"good":sim>=0.5?"ok":"bad"; }
function stopRec(){ if(currentRec){ try{currentRec.stop();}catch(e){} currentRec=null; } }
function startRec(onResult, onEnd){
if(!REC){ toast("Микрофон не поддерживается в этом браузере 🙁 Попробуй Chrome"); return false; }
stopRec(); stopSpeak();
var r=new REC();
r.lang="en-US"; r.interimResults=false; r.maxAlternatives=3;
r.onresult=function(e){
var t=""; for(var i=0;i<e.results.length;i++){ t+=e.results[i][0].transcript; }
onResult(t);
};
r.onerror=function(){ onEnd&&onEnd(); };
r.onend=function(){ onEnd&&onEnd(); };
currentRec=r;
try{ r.start(); return true; }catch(e){ onEnd&&onEnd(); return false; }
}
/* ---------- utils ---------- */
function $(id){ return document.getElementById(id); }
function esc(s){ return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
var toastT;
function toast(msg){
var t=$("toast"); t.textContent=msg; t.classList.add("show");
clearTimeout(toastT); toastT=setTimeout(function(){ t.classList.remove("show"); },2200);
}
/* ---------- render: header + dashboard ---------- */
function renderTop(){
$("streak").innerHTML="🔥 "+state.streak;
$("level").textContent="Intermediate";
$("bellBtn").classList.toggle("on", !!state.reminderOn);
}
function renderDashboard(){
var elapsed = dayKey+1;
var total=180;
var pct = Math.min(100, Math.round(elapsed/total*100));
var left = Math.max(0, total-elapsed);
$("goalTitle").textContent = "Цель: свободный английский за 6 месяцев";
$("goalSub").textContent = "Ты на пути! Каждый день — новый шаг к свободной речи с коллегами.";
$("goalBar").style.width = pct+"%";
$("goalMetaL").innerHTML = "День <b>"+elapsed+"</b> из "+total;
$("goalMetaR").innerHTML = "Осталось <b>"+left+"</b> дн.";
// mission chips
var h="";
SECTIONS.forEach(function(s){
var d=isDone(s.id);
h+='<div class="mchip '+(d?"done":"")+'" data-sec="'+s.id+'"><span class="ic">'+s.icon+'</span>'+s.label+'</div>';
});
$("mission").innerHTML=h;
$("mission").querySelectorAll(".mchip").forEach(function(el){
el.addEventListener("click",function(){ switchTab(el.getAttribute("data-sec")); window.scrollTo({top:0,behavior:"smooth"}); });
});
}
/* ---------- render: tabs ---------- */
function renderTabs(){
var h="";
SECTIONS.forEach(function(s){
h+='<button class="tab '+(ACTIVE===s.id?"active":"")+'" data-sec="'+s.id+'"><span class="dot"></span>'+s.icon+" "+s.label+'</button>';
});
h+='<button class="tab tab-test '+(ACTIVE==="test"?"active":"")+'" data-sec="test">🎯 Тест</button>';
$("tabrow").innerHTML=h;
$("tabrow").querySelectorAll(".tab").forEach(function(el){
el.addEventListener("click",function(){ switchTab(el.getAttribute("data-sec")); });
});
}
function switchTab(id){
stopSpeak(); stopRec();
ACTIVE=id;
document.querySelectorAll(".tab").forEach(function(el){ el.classList.toggle("active", el.getAttribute("data-sec")===id); });
document.querySelectorAll(".panel").forEach(function(p){ p.classList.remove("active"); });
$(id).classList.add("active");
renderPanel(id);
var isTest = id==="test";
$("dayBar").style.display = isTest ? "none" : "";
document.querySelector(".done-bar").style.display = isTest ? "none" : "";
document.body.style.paddingBottom = isTest ? "24px" : "84px";
if(!isTest) renderDoneBar();
}
/* ---------- day bar ---------- */
function renderDayBar(){
$("dayTag").innerHTML = 'День <b>'+(curIdx+1)+'</b> / '+N+(curIdx===todayContentIdx?' · сегодня':' · повторение');
}
function bindDayNav(){
$("prevDay").onclick=function(){ if(ACTIVE==="test") return; curIdx=(curIdx-1+N)%N; rerender(); };
$("nextDay").onclick=function(){ if(ACTIVE==="test") return; curIdx=(curIdx+1)%N; rerender(); };
}
/* ---------- panel router ---------- */
function renderPanel(id){
var d=day();
if(id==="words") renderWords(d);
if(id==="dialogue") renderDialogue(d);
if(id==="reading") renderReading(d);
if(id==="listening") renderListening(d);
if(id==="speaking") renderSpeaking(d);
if(id==="writing") renderWriting(d);
if(id==="test") renderTest();
}
function renderTopicCard(d){
return '<div class="topic-card"><div class="em">'+d.emoji+'</div><div><div class="t">'+esc(d.topic)+'</div><div class="s">Тема дня · Intermediate</div></div></div>';
}
/* ---------- words ---------- */
function renderWords(d){
var h=renderTopicCard(d);
h+='<div class="card"><h3>📖 Новые слова ('+d.words.length+')</h3><div class="hint">Нажми на карточку, чтобы увидеть значение. ▶ — послушать произношение.</div>';
d.words.forEach(function(w,i){
var learned = state.wordsLearned.indexOf(w.en)>=0;
h+='<div class="word" data-i="'+i+'"><div class="word-top"><div><div class="word-en">'+esc(w.en)+'</div><div class="word-ru">'+esc(w.ru)+'</div></div><button class="play" data-play="'+i+'">▶</button></div><div class="word-def">'+esc(w.def)+'</div><div class="word-ex">"'+esc(w.ex)+'"</div><div class="stars">'+(learned?"⭐ Изучено":"")+'</div></div>';
});
h+='<div class="btnrow" style="margin-top:14px"><button class="btn ghost" id="playAll">▶ Все слова</button></div>';
h+='</div>';
$("words").innerHTML=h;
$("words").querySelectorAll(".word").forEach(function(el){
el.addEventListener("click",function(e){
if(e.target.closest(".play")) return;
el.classList.toggle("open");
});
});
$("words").querySelectorAll(".play").forEach(function(el){
el.addEventListener("click",function(e){
e.stopPropagation();
var i=+el.getAttribute("data-play");
var w=d.words[i];
stopSpeak(); speak(w.en);
if(state.wordsLearned.indexOf(w.en)<0){ state.wordsLearned.push(w.en); save(); }
var card=el.closest(".word"); card.querySelector(".stars").textContent="⭐ Изучено";
});
});
$("playAll").onclick=function(){
stopSpeak();
var p=Promise.resolve();
d.words.forEach(function(w){ p=p.then(function(){ return speak(w.en,{rate:0.95}).then(function(){ return new Promise(function(r){ setTimeout(r,250); }); }); }); });
};
}
/* ---------- dialogue ---------- */
function renderDialogue(d){
var h=renderTopicCard(d);
h+='<div class="card"><h3>💬 Диалог дня</h3><div class="hint">▶ — проиграть. Нажми строку, чтобы увидеть перевод.</div>';
h+='<div class="btnrow" style="margin-bottom:14px"><button class="btn primary" id="playAllDlg">▶ Весь диалог</button><button class="btn ghost" id="toggleTr">Перевод</button></div>';
h+='<div class="lines" id="lines">';
d.dialogue.forEach(function(l,i){
h+='<div class="line '+l.who+'" data-i="'+i+'"><div class="bubble"><div class="who">'+esc(l.name)+'</div>'+esc(l.text)+'<div class="tr">'+esc(l.tr)+'</div></div></div>';
});
h+='</div>';
h+='<div class="tips">Совет: повторяй реплики вслух за диктором — это тренирует произношение и скорость речи.</div>';
h+='</div>';
$("dialogue").innerHTML=h;
$("lines").querySelectorAll(".line").forEach(function(el){
el.addEventListener("click",function(){ el.classList.toggle("show-tr"); });
});
$("toggleTr").onclick=function(){
var all=$("lines").querySelectorAll(".line");
var anyOff=false; all.forEach(function(el){ if(!el.classList.contains("show-tr")) anyOff=true; });
all.forEach(function(el){ el.classList.toggle("show-tr", anyOff); });
};
$("playAllDlg").onclick=function(){
stopSpeak(); stopRec();
var p=Promise.resolve();
d.dialogue.forEach(function(l){
p=p.then(function(){
return speak(l.text,{rate:0.95, pitch: l.who==="A"?1:1.15, voice: l.who==="A"?"Male":"Female"});
}).then(function(){ return new Promise(function(r){ setTimeout(r,350); }); });
});
};
}
/* ---------- reading ---------- */
function renderReading(d){
var r=d.reading;
var h=renderTopicCard(d);
h+='<div class="card"><h3>📰 '+esc(r.title)+'</h3><div class="hint">Прочитай текст и ответь на вопросы.</div>';
h+='<p style="margin-bottom:16px;line-height:1.7">'+esc(r.text)+'</p>';
r.questions.forEach(function(qi,idx){
h+='<div class="q" data-q="'+idx+'"><div class="qt">'+(idx+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){
h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>';
});
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
});
h+='<div class="score" id="rScore"></div></div>';
$("reading").innerHTML=h;
bindQuiz("reading", r.questions);
}
/* ---------- listening ---------- */
function renderListening(d){
var l=d.listening;
var h=renderTopicCard(d);
h+='<div class="card"><h3>🎧 Аудирование</h3><div class="hint">Послушай аудио и ответь на вопросы. Можно менять скорость.</div>';
h+='<div class="audio-controls"><button class="btn primary" id="playL">▶ Слушать</button><div class="speed"><button data-sp="0.7">0.7×</button><button class="on" data-sp="1">1×</button><button data-sp="1.2">1.2×</button></div></div>';
h+='<button class="btn outline" id="showText" style="margin-bottom:14px">Показать текст</button>';
h+='<div class="listen-text" id="lText">'+esc(l.text)+'</div>';
l.questions.forEach(function(qi,idx){
h+='<div class="q" data-q="'+idx+'"><div class="qt">'+(idx+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){
h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>';
});
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
});
h+='<div class="score" id="lScore"></div></div>';
$("listening").innerHTML=h;
var rate=1;
$("playL").onclick=function(){ stopSpeak(); speak(l.text,{rate:rate}); };
$("listening").querySelectorAll(".speed button").forEach(function(b){
b.onclick=function(){
rate=parseFloat(b.getAttribute("data-sp"));
$("listening").querySelectorAll(".speed button").forEach(function(x){ x.classList.remove("on"); });
b.classList.add("on");
};
});
$("showText").onclick=function(){ $("lText").classList.toggle("show"); this.textContent=$("lText").classList.contains("show")?"Скрыть текст":"Показать текст"; };
bindQuiz("listening", l.questions);
}
/* ---------- quiz shared ---------- */
function bindQuiz(panelId, questions){
var root=$(panelId);
root.querySelectorAll(".q").forEach(function(qEl, qIdx){
var answered=false;
qEl.querySelectorAll(".opt").forEach(function(oEl){
oEl.addEventListener("click",function(){
if(answered) return; answered=true;
var o=+oEl.getAttribute("data-o");
var correct=questions[qIdx].answer;
qEl.querySelectorAll(".opt").forEach(function(x){ x.classList.add("disabled"); });
if(o===correct){ oEl.classList.add("correct"); }
else{ oEl.classList.add("wrong"); var c=qEl.querySelector('.opt[data-o="'+correct+'"]'); if(c) c.classList.add("correct"); }
qEl.querySelector(".explain").classList.add("show");
updateScore(root, questions.length, panelId);
});
});
});
}
function updateScore(root, total, panelId){
var got=0;
root.querySelectorAll(".q").forEach(function(qEl){
if(qEl.querySelector(".opt.correct") && !qEl.querySelector(".opt.wrong")) got++;
});
var sc=$(panelId==="reading"?"rScore":"lScore");
if(sc){
sc.textContent="Результат: "+got+" / "+total+(got===total?" 🎉 Отлично!":"");
sc.classList.add("show");
}
}
/* ---------- speaking ---------- */
function renderSpeaking(d){
var s=d.speaking;
var sup = !!REC;
var h=renderTopicCard(d);
h+='<div class="card"><h3>🎤 Практика говорения</h3><div class="hint">Прочитай задание и ответь вслух. Я распознаю речь и сравню с образцом.</div>';
h+='<div class="tips" style="margin-bottom:14px"><b>Задание:</b> '+esc(s.prompt)+'</div>';
h+='<div class="speak-box">';
if(sup){
h+='<button class="mic" id="mic">🎤</button>';
h+='<div id="micState">Нажми на микрофон и говори</div>';
} else {
h+='<div style="font-size:40px">🎤</div>';
h+='<div class="rec-badge">Микрофон недоступен в этом браузере</div>';
h+='<div style="font-size:13px;color:var(--gray-500)">Открой в Chrome на телефоне или компьютере, чтобы практиковать речь.</div>';
}
h+='<div class="transcript" id="tr"><span class="ph">Твой ответ появится здесь…</span></div>';
h+='<div class="btnrow"><button class="btn ghost" id="hearModel">▶ Образец</button><button class="btn outline" id="showTarget">Показать ответ</button></div>';
h+='<div class="target" id="target"><b>Образцовый ответ:</b>'+esc(s.target)+'</div>';
h+='</div>';
h+='<div class="tips">💡 Не бойся ошибок — главное пробовать говорить вслух каждый день. Это ключ к свободной речи.</div>';
h+='</div>';
$("speaking").innerHTML=h;
$("hearModel").onclick=function(){ stopSpeak(); speak(s.target,{rate:0.9}); };
$("showTarget").onclick=function(){ $("target").classList.toggle("show"); };
if(sup){
var mic=$("mic"), state2=$("micState"), tr=$("tr");
mic.onclick=function(){
if(mic.classList.contains("rec")){ stopRec(); return; }
var ok=startRec(function(t){
tr.innerHTML=esc(t);
var sim=similarity(t, s.target);
var lv=levelOf(sim);
var label = lv==="good"?"Отлично! 🎉":lv==="ok"?"Неплохо, почти! 👍":"Попробуй ещё раз 💪";
var tr2=document.createElement("div"); tr2.className="match "+lv; tr2.textContent=label+" ("+Math.round(sim*100)+"% совпадение)";
tr.appendChild(tr2);
},function(){
mic.classList.remove("rec"); mic.textContent="🎤"; state2.textContent="Нажми на микрофон и говори";
});
if(ok){ mic.classList.add("rec"); mic.textContent="⏹"; state2.innerHTML='<span class="rec-badge"><span class="blink"></span>Слушаю…</span>'; }
};
}
}
/* ---------- writing ---------- */
function renderWriting(d){
var w=d.writing;
var key="w_"+todayStr()+"_"+curIdx;
var saved=localStorage.getItem(key)||"";
var h=renderTopicCard(d);
h+='<div class="card"><h3>✍️ Практика письма</h3><div class="hint">Напиши ответ и сравни с образцом.</div>';
h+='<div class="tips" style="margin-bottom:14px"><b>Задание:</b> '+esc(w.prompt)+'</div>';
h+='<textarea id="ta" placeholder="Пиши здесь на английском…">'+esc(saved)+'</textarea>';
h+='<div class="btnrow" style="margin-top:10px"><button class="btn primary" id="saveW">Сохранить</button><button class="btn ghost" id="showModel">Показать образец</button></div>';
h+='<div class="model" id="model"><b>Образцовый ответ:</b>'+esc(w.model)+'</div>';
h+='<div class="tips" style="margin-top:12px">💡 '+esc(w.tips)+'</div>';
h+='</div>';
$("writing").innerHTML=h;
$("saveW").onclick=function(){ localStorage.setItem(key, $("ta").value); toast("Сохранено ✅"); };
$("showModel").onclick=function(){ $("model").classList.toggle("show"); };
}
/* ---------- done bar ---------- */
function renderDoneBar(){
var sec=SECTIONS.filter(function(s){return s.id===ACTIVE;})[0];
var done=isDone(ACTIVE);
var c=completedToday();
$("doneInfo").innerHTML = 'Сегодня выполнено: <b>'+c.length+"/"+SECTIONS.length+'</b> · '+sec.label;
var b=$("doneBtn");
b.textContent=done?"✓ Выполнено":"Отметить выполненным";
b.classList.toggle("done", done);
}
function bindDoneBar(){
$("doneBtn").onclick=function(){
var on=!isDone(ACTIVE);
setDone(ACTIVE, on);
renderDoneBar();
renderDashboard();
if(on){
var c=completedToday();
if(c.length===SECTIONS.length) toast("Все задания дня готовы! Так держать 🚀");
else toast("Готово! +1 к прогрессу 💪");
}
};
}
/* ---------- rerender ---------- */
function rerender(){
renderDayBar();
renderPanel(ACTIVE);
renderDoneBar();
}
/* ---------- reminders ---------- */
function openReminder(){
$("remTime").value=state.reminderTime;
$("remOn").checked=!!state.reminderOn;
var perm = window.Notification ? Notification.permission : "unsupported";
$("remPerm").textContent = perm==="granted"?"Уведомления разрешены ✅":perm==="denied"?"Уведомления заблокированы в браузере":"Разрешение ещё не запрошено";
$("remModal").classList.add("show");
}
function closeReminder(){ $("remModal").classList.remove("show"); }
function saveReminder(){
state.reminderTime=$("remTime").value||"20:00";
var on=$("remOn").checked;
if(on && window.Notification && Notification.permission!=="granted" && Notification.permission!=="denied"){
Notification.requestPermission();
}
state.reminderOn=on; save();
renderTop();
scheduleReminder();
closeReminder();
toast(on ? ("Напоминания включены на "+state.reminderTime) : "Напоминания выключены");
}
function maybeNotify(){
if(!state.reminderOn) return;
var c=completedToday();
if(c.length>=SECTIONS.length) return;
var today=todayStr();
if(state.lastNotified===today) return;
var now=new Date();
var hh=+state.reminderTime.slice(0,2), mm=+state.reminderTime.slice(3,5);
if(now.getHours()*60+now.getMinutes() < hh*60+mm) return;
state.lastNotified=today; save();
showBanner();
if(window.Notification && Notification.permission==="granted"){
try{ new Notification("SpeakDaily 🔔",{body:"Пора заниматься английским! Осталось "+(SECTIONS.length-c.length)+" блок(ов) сегодня."}); }catch(e){}
}
}
function showBanner(){
var b=$("reminderBanner");
var c=completedToday();
b.innerHTML='<span>🔔 Пора заниматься! Осталось '+(SECTIONS.length-c.length)+' из '+SECTIONS.length+' блоков сегодня.</span><button class="link" id="banGo">Начать →</button>';
b.hidden=false;
$("banGo").onclick=function(){ switchTab("words"); window.scrollTo({top:0,behavior:"smooth"}); };
}
function scheduleReminder(){
clearTimeout(window._remT);
if(!state.reminderOn) return;
var now=new Date();
var hh=+state.reminderTime.slice(0,2), mm=+state.reminderTime.slice(3,5);
var target=new Date(now); target.setHours(hh,mm,0,0);
if(target<=now) target.setDate(target.getDate()+1);
window._remT=setTimeout(function(){ maybeNotify(); scheduleReminder(); }, target-now+1000);
}
/* ---------- monthly test ---------- */
var testState=null;
function genTest(){
var allWords=flatten(CUR.map(function(d){return d.words;}));
var wq=shuffle(allWords).slice(0,6).map(function(w){
var distr=shuffle(allWords.filter(function(x){return x.en!==w.en;})).slice(0,2).map(function(x){return x.ru;});
var opts=shuffle([w.ru].concat(distr));
return {q:'Перевод слова: "'+w.en+'"', options:opts, answer:opts.indexOf(w.ru), ex:'Правильно: '+w.ru};
});
var dq=[];
shuffle(CUR.slice()).slice(0,3).forEach(function(d){
var i=1+Math.floor(Math.random()*(d.dialogue.length-1));
var prev=d.dialogue[i-1], cur=d.dialogue[i];
var pool=flatten(CUR.map(function(x){return x.dialogue;})).filter(function(l){return l.text!==cur.text;});
var distr=shuffle(pool).slice(0,2).map(function(l){return l.text;});
var opts=shuffle([cur.text].concat(distr));
dq.push({q:'После реплики: "'+prev.text+'" что отвечает '+cur.name+'?', options:opts, answer:opts.indexOf(cur.text), ex:cur.text});
});
var rq=[]; shuffle(CUR.slice()).slice(0,2).forEach(function(d){ var qi=d.reading.questions[Math.floor(Math.random()*d.reading.questions.length)]; rq.push({title:d.reading.title, q:qi.q, options:qi.options, answer:qi.answer, ex:qi.ex}); });
var lq=[]; shuffle(CUR.slice()).slice(0,2).forEach(function(d){ var qi=d.listening.questions[Math.floor(Math.random()*d.listening.questions.length)]; lq.push({audio:d.listening.text, q:qi.q, options:qi.options, answer:qi.answer, ex:qi.ex}); });
var sq=shuffle(CUR.slice()).slice(0,2).map(function(d){return d.speaking;});
var wri=shuffle(CUR.slice())[0].writing;
return {words:wq, dialogue:dq, reading:rq, listening:lq, speaking:sq, writing:wri, answers:{speaking:{}, writing:0}};
}
function qhtml(block,i,qi){
var h='<div class="q" data-block="'+block+'" data-i="'+i+'"><div class="qt">'+(i+1)+'. '+esc(qi.q)+'</div><div class="opts">';
qi.options.forEach(function(op,oi){ h+='<button class="opt" data-o="'+oi+'">'+esc(op)+'</button>'; });
h+='</div><div class="explain">'+esc(qi.ex)+'</div></div>';
return h;
}
function speakTestHtml(i,s){
var sup=!!REC;
var h='<div class="speak-box" style="margin-bottom:12px" data-sp="'+i+'"><div class="tips" style="width:100%;margin:0 0 6px"><b>Задание:</b> '+esc(s.prompt)+'</div>';
if(sup){ h+='<button class="mic tmic" data-i="'+i+'">🎤</button><div class="tstate" data-i="'+i+'">Нажми и говори</div>'; }
else { h+='<div style="font-size:32px">🎤</div><div class="rec-badge">Микрофон недоступен</div>'; }
h+='<div class="transcript ttr" data-i="'+i+'"><span class="ph">Твой ответ…</span></div>';
h+='<div class="btnrow"><button class="btn ghost thear" data-i="'+i+'">▶ Образец</button><button class="btn outline tshow" data-i="'+i+'">Показать ответ</button></div>';
h+='<div class="target ttarget" data-i="'+i+'"><b>Образец:</b>'+esc(s.target)+'</div></div>';
return h;
}
function renderTest(){
var m=currentMonth();
var past=state.tests[m];
var h='<div class="topic-card"><div class="em">🎯</div><div><div class="t">Тест месяца</div><div class="s">'+monthLabel(m)+' · проверка по всем блокам</div></div></div>';
h+='<div class="card"><h3>📅 История тестов</h3>';
var any=false;
for(var i=0;i<=5;i++){
if(state.tests[i]){ any=true; h+='<div class="mrow"><span>'+monthLabel(i)+(i===m?' · текущий':'')+'</span><b>'+state.tests[i].pct+'%</b></div>'; }
else { h+='<div class="mrow"><span class="miss">'+monthLabel(i)+(i===m?' · текущий':'')+'</span><span class="miss">не пройден</span></div>'; }
}
if(!any) h+='<div class="hint" style="margin-top:6px">Пройди тест в конце месяца, чтобы проверить прогресс по каждому блоку.</div>';
h+='</div>';
if(testState){
h+='<div class="card"><h3>📖 Блок: Слова</h3>'; testState.words.forEach(function(qi,i){ h+=qhtml("words",i,qi); }); h+='</div>';
h+='<div class="card"><h3>💬 Блок: Диалог</h3>'; testState.dialogue.forEach(function(qi,i){ h+=qhtml("dialogue",i,qi); }); h+='</div>';
h+='<div class="card"><h3>📰 Блок: Чтение</h3>'; testState.reading.forEach(function(qi,i){ if(qi.title) h+='<div class="hint" style="margin-bottom:4px">Текст: '+esc(qi.title)+'</div>'; h+=qhtml("reading",i,qi); }); h+='</div>';
h+='<div class="card"><h3>🎧 Блок: Аудирование</h3>'; testState.listening.forEach(function(qi,i){ h+='<div class="btnrow" style="margin-bottom:8px"><button class="btn ghost tplay" data-i="'+i+'">▶ Прослушать</button></div>'; h+=qhtml("listening",i,qi); }); h+='</div>';
h+='<div class="card"><h3>🎤 Блок: Говорение</h3>'; testState.speaking.forEach(function(s,i){ h+=speakTestHtml(i,s); }); h+='</div>';
h+='<div class="card"><h3>✍️ Блок: Письмо</h3>';
h+='<div class="tips" style="margin-bottom:10px"><b>Задание:</b> '+esc(testState.writing.prompt)+'</div>';
h+='<textarea id="tw" placeholder="Пиши на английском…"></textarea>';
h+='<div class="btnrow" style="margin-top:8px"><button class="btn ghost" id="twModel">Показать образец</button></div>';
h+='<div class="model" id="twModelBox"><b>Образец:</b>'+esc(testState.writing.model)+'</div>';
h+='<div class="hint" style="margin-top:10px">Сравни с образцом и оцени себя:</div>';
h+='<div class="btnrow" style="margin-top:6px"><button class="btn outline wrate" data-r="0">0 — плохо</button><button class="btn outline wrate" data-r="1">1 — средне</button><button class="btn outline wrate" data-r="2">2 — хорошо</button></div>';
h+='</div>';
h+='<button class="btn primary block" id="finishTest" style="margin-top:6px">Завершить тест месяца</button>';
} else if(past){
h+='<div class="card"><h3>✅ Результат за '+monthLabel(m)+'</h3><div class="result-grid">';
SECTIONS.forEach(function(s){ var r=past[s.id]; var cls=r?(r.score/r.total>=0.7?"good":r.score/r.total>=0.4?"ok":"bad"):""; h+='<div class="rcell '+cls+'"><div class="ric">'+s.icon+'</div><div class="rlabel">'+s.label.toUpperCase()+'</div><div class="rscore">'+(r?r.score+'/'+r.total:'—')+'</div></div>'; });
h+='</div><div class="score show" style="margin-top:12px">Итог: '+past.pct+'%</div>';
h+='<div class="btnrow" style="margin-top:14px"><button class="btn primary" id="retakeTest">Пройти заново</button></div></div>';
} else {
h+='<div class="card"><h3>Готов к проверке?</h3><div class="hint">Тест по 6 блокам: слова, диалог, чтение, аудио, говорение и письмо. Вопросы собираются из всех тем курса.</div><button class="btn primary block" id="startTest">Начать тест месяца</button></div>';
}
$("test").innerHTML=h;
if($("startTest")) $("startTest").onclick=function(){ testState=genTest(); renderTest(); window.scrollTo({top:0,behavior:"smooth"}); };
if($("retakeTest")) $("retakeTest").onclick=function(){ testState=genTest(); renderTest(); window.scrollTo({top:0,behavior:"smooth"}); };
if($("finishTest")) $("finishTest").onclick=finishTest;
if(testState) bindTestInteractions();
}
function bindTestInteractions(){
var root=$("test");
// MCQ
root.querySelectorAll(".q").forEach(function(qEl){
var answered=false;
qEl.querySelectorAll(".opt").forEach(function(oEl){
oEl.addEventListener("click",function(){
if(answered) return; answered=true;
var block=qEl.getAttribute("data-block");
var o=+oEl.getAttribute("data-o");
var qs=testState[block][+qEl.getAttribute("data-i")];
var correct=qs.answer;
qEl.querySelectorAll(".opt").forEach(function(x){ x.classList.add("disabled"); });
if(o===correct){ oEl.classList.add("correct"); }
else{ oEl.classList.add("wrong"); var c=qEl.querySelector('.opt[data-o="'+correct+'"]'); if(c) c.classList.add("correct"); }
qEl.querySelector(".explain").classList.add("show");
});
});
});
// listening play
root.querySelectorAll(".tplay").forEach(function(b){
b.onclick=function(){ var i=+b.getAttribute("data-i"); stopSpeak(); speak(testState.listening[i].audio); };
});
// speaking
root.querySelectorAll(".tmic").forEach(function(mic){
mic.onclick=function(){ var i=+mic.getAttribute("data-i"); var s=testState.speaking[i];
if(mic.classList.contains("rec")){ stopRec(); return; }
var ok=startRec(function(t){
var tr=root.querySelector('.ttr[data-i="'+i+'"]'); tr.innerHTML=esc(t);
var sim=similarity(t,s.target); testState.answers.speaking[i]=sim;
var lv=levelOf(sim);
var d=document.createElement("div"); d.className="match "+lv; d.textContent=(lv==="good"?"Отлично!":lv==="ok"?"Неплохо!":"Ещё раз")+" ("+Math.round(sim*100)+"%)";
tr.appendChild(d);
},function(){ mic.classList.remove("rec"); mic.textContent="🎤"; root.querySelector('.tstate[data-i="'+i+'"]').textContent="Нажми и говори"; });
if(ok){ mic.classList.add("rec"); mic.textContent="⏹"; root.querySelector('.tstate[data-i="'+i+'"]').innerHTML='<span class="rec-badge"><span class="blink"></span>Слушаю…</span>'; }
};
});
root.querySelectorAll(".thear").forEach(function(b){ b.onclick=function(){ var i=+b.getAttribute("data-i"); stopSpeak(); speak(testState.speaking[i].target,{rate:0.9}); }; });
root.querySelectorAll(".tshow").forEach(function(b){ b.onclick=function(){ var i=+b.getAttribute("data-i"); root.querySelector('.ttarget[data-i="'+i+'"]').classList.toggle("show"); }; });
// writing
if($("twModel")) $("twModel").onclick=function(){ $("twModelBox").classList.toggle("show"); };
root.querySelectorAll(".wrate").forEach(function(b){
b.onclick=function(){ testState.answers.writing=+b.getAttribute("data-r");
root.querySelectorAll(".wrate").forEach(function(x){ x.classList.remove("primary"); x.classList.add("outline"); });
b.classList.remove("outline"); b.classList.add("primary");
};
});
}
function finishTest(){
var t=testState, ans=t.answers;
function blockScore(block){ var got=0,total=0; $("test").querySelectorAll('.q[data-block="'+block+'"]').forEach(function(qEl){ total++; if(qEl.querySelector(".opt.correct")&&!qEl.querySelector(".opt.wrong")) got++; }); return {score:got,total:total}; }
var res={};
res.words=blockScore("words");
res.dialogue=blockScore("dialogue");
res.reading=blockScore("reading");
res.listening=blockScore("listening");
var sg=0; t.speaking.forEach(function(s,i){ if((ans.speaking[i]||0)>=0.6) sg++; }); res.speaking={score:sg,total:t.speaking.length};
res.writing={score:ans.writing||0,total:2};
var ts=0,tt=0; SECTIONS.forEach(function(s){ ts+=res[s.id].score; tt+=res[s.id].total; });
res.pct=Math.round(ts/tt*100); res.date=todayStr();
var m=currentMonth(); state.tests[m]=res; save();
testState=null;
renderTest();
toast("Тест завершён! Итог: "+res.pct+"%");
window.scrollTo({top:0,behavior:"smooth"});
}
/* ---------- init ---------- */
function init(){
load();
computeDay();
renderTop();
renderDashboard();
renderTabs();
renderDayBar();
bindDayNav();
bindDoneBar();
$("bellBtn").onclick=openReminder;
$("remSave").onclick=saveReminder;
$("remCancel").onclick=closeReminder;
$("remModal").addEventListener("click",function(e){ if(e.target===$("remModal")) closeReminder(); });
scheduleReminder();
maybeNotify();
switchTab("words");
}
if(document.readyState!=="loading") init();
else document.addEventListener("DOMContentLoaded", init);
})();