670 lines
35 KiB
JavaScript
670 lines
35 KiB
JavaScript
(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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||
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);
|
||
})();
|