Files
notes-qpid-pro/app/static/js/modules/srs.js
T

1023 lines
45 KiB
JavaScript

// =======================================================================
// Notes Application - SRS Training & Analytics Module (Native ES Module)
// =======================================================================
import { api } from './api.js';
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, cleanPromptTranslation } from './ui.js';
import { getAllCards } from './dictionary.js';
let srsQueue = [];
let srsCurrentIndex = 0;
let srsCurrentMode = 'flashcards'; // 'flashcards' | 'quiz' | 'typing' | 'cloze' | 'dictation'
let srsDirection = 'en-ru';
let srsSmartMix = true;
let srsSessionRatings = [];
let srsIsFlipped = false;
let quizCurrentOptions = [];
let quizAnswered = false;
export function initSrs() {
const srsView = document.getElementById('srs-view');
const srsExitBtn = document.getElementById('srs-exit-btn');
const srsTabs = document.querySelectorAll('.srs-tab');
const srsProgressFill = document.getElementById('srs-progress-fill');
const srsCounter = document.getElementById('srs-counter');
const srsDirBtn = document.getElementById('srs-dir-btn');
const srsDirLabel = document.getElementById('srs-dir-label');
const srsMixBtn = document.getElementById('srs-mix-btn');
const srsMixLabel = document.getElementById('srs-mix-label');
const srsLoading = document.getElementById('srs-loading');
const srsEmpty = document.getElementById('srs-empty');
const srsTrainAllBtn = document.getElementById('srs-train-all-btn');
const srsEmptyBackBtn = document.getElementById('srs-empty-back-btn');
// Mode 1: Flashcards
const srsModeFlashcards = document.getElementById('srs-mode-flashcards');
const flashcardFlipWrap = document.getElementById('flashcard-flip-wrap');
const flashcardInner = document.getElementById('flashcard-inner');
const fcFrontDirBadge = document.getElementById('fc-front-dir-badge');
const fcFrontPos = document.getElementById('fc-front-pos');
const fcFrontMain = document.getElementById('fc-front-main');
const fcFrontPhoneticWrap = document.getElementById('fc-front-phonetic-wrap');
const fcFrontPhonetic = document.getElementById('fc-front-phonetic');
const fcFrontAudioBtn = document.getElementById('fc-front-audio-btn');
const fcHintBtn = document.getElementById('fc-hint-btn');
const fcHintBox = document.getElementById('fc-hint-box');
const fcBackPos = document.getElementById('fc-back-pos');
const fcBackTerm = document.getElementById('fc-back-term');
const fcBackAudioBtn = document.getElementById('fc-back-audio-btn');
const fcBackTranslation = document.getElementById('fc-back-translation');
const fcBackDefBox = document.getElementById('fc-back-def-box');
const fcBackDef = document.getElementById('fc-back-def');
const fcBackContextBox = document.getElementById('fc-back-context-box');
const fcBackContext = document.getElementById('fc-back-context');
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
const fcBackNotes = document.getElementById('fc-back-notes');
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
const fcFlipBtn = document.getElementById('fc-flip-btn');
const fcRatingBar = document.getElementById('fc-rating-bar');
// Mode 2: Multiple Choice Quiz
const srsModeQuiz = document.getElementById('srs-mode-quiz');
const quizPos = document.getElementById('quiz-pos');
const quizTermDisplay = document.getElementById('quiz-term-display');
const quizPhoneticDisplay = document.getElementById('quiz-phonetic-display');
const quizContextHint = document.getElementById('quiz-context-hint');
const quizAudioBtn = document.getElementById('quiz-audio-btn');
const quizOptionsGrid = document.getElementById('quiz-options-grid');
const quizFeedbackBox = document.getElementById('quiz-feedback-box');
const quizDetailsBox = document.getElementById('quiz-details-box');
const quizNextBar = document.getElementById('quiz-next-bar');
const quizNextBtn = document.getElementById('quiz-next-btn');
// Mode 3: Active Typing
const srsModeTyping = document.getElementById('srs-mode-typing');
const typingPos = document.getElementById('typing-pos');
const typingPromptRu = document.getElementById('typing-prompt-ru');
const typingContextHint = document.getElementById('typing-context-hint');
const typingForm = document.getElementById('typing-form');
const typingInput = document.getElementById('typing-input');
const typingFeedbackBox = document.getElementById('typing-feedback-box');
const typingDetailsBox = document.getElementById('typing-details-box');
const typingRatingBar = document.getElementById('typing-rating-bar');
// Mode 4: Cloze Deletion
const srsModeCloze = document.getElementById('srs-mode-cloze');
const clozePos = document.getElementById('cloze-pos');
const clozeTargetTranslation = document.getElementById('cloze-target-translation');
const clozeSentenceDisplay = document.getElementById('cloze-sentence-display');
const clozeForm = document.getElementById('cloze-form');
const clozeInput = document.getElementById('cloze-input');
const clozeFeedbackBox = document.getElementById('cloze-feedback-box');
const clozeDetailsBox = document.getElementById('cloze-details-box');
const clozeRatingBar = document.getElementById('cloze-rating-bar');
// Mode 5: Dictation
const srsModeDictation = document.getElementById('srs-mode-dictation');
const dictationBigPlayBtn = document.getElementById('dictation-big-play-btn');
const dictationReplayBtn = document.getElementById('dictation-replay-btn');
const dictationToggleHintBtn = document.getElementById('dictation-toggle-hint-btn');
const dictationHintText = document.getElementById('dictation-hint-text');
const dictationForm = document.getElementById('dictation-form');
const dictationInput = document.getElementById('dictation-input');
const dictationFeedbackBox = document.getElementById('dictation-feedback-box');
const dictationDetailsBox = document.getElementById('dictation-details-box');
const dictationRatingBar = document.getElementById('dictation-rating-bar');
// Mode 6: Listening Comprehension
const srsModeListening = document.getElementById('srs-mode-listening');
const listeningLevelBadge = document.getElementById('listening-level-badge');
const listeningTopicBadge = document.getElementById('listening-topic-badge');
const listeningTopicSelect = document.getElementById('listening-topic-select');
const listeningVoiceSelect = document.getElementById('listening-voice-select');
const listeningSpeedSelect = document.getElementById('listening-speed-select');
const listeningNewScenarioBtn = document.getElementById('listening-new-scenario-btn');
const listeningTitle = document.getElementById('listening-title');
const listeningPlayStoryBtn = document.getElementById('listening-play-story-btn');
const listeningPlayQuestionBtn = document.getElementById('listening-play-question-btn');
const listeningToggleHintBtn = document.getElementById('listening-toggle-hint-btn');
const listeningHintText = document.getElementById('listening-hint-text');
const listeningForm = document.getElementById('listening-form');
const listeningInput = document.getElementById('listening-input');
const listeningSubmitBtn = document.getElementById('listening-submit-btn');
const listeningFeedbackBox = document.getElementById('listening-feedback-box');
const listeningRevealedBox = document.getElementById('listening-revealed-box');
const listeningIdealAnswer = document.getElementById('listening-ideal-answer');
const listeningRevealedQuestion = document.getElementById('listening-revealed-question');
const listeningRevealedQuestionRu = document.getElementById('listening-revealed-question-ru');
const listeningRevealedStory = document.getElementById('listening-revealed-story');
const listeningRevealedStoryRu = document.getElementById('listening-revealed-story-ru');
const listeningNextBtn = document.getElementById('listening-next-btn');
let currentListeningExercise = null;
let listeningAnswered = false;
let listeningCompletedCount = 0;
// Summary
const srsSummary = document.getElementById('srs-summary');
const sumCount = document.getElementById('sum-count');
const sumRetention = document.getElementById('sum-retention');
const sumRepeatBtn = document.getElementById('sum-repeat-btn');
const sumReturnBtn = document.getElementById('sum-return-btn');
// Stats & Heatmap Modal
const statsOpenBtn = document.getElementById('stats-open-btn');
const mobileStatsBtn = document.getElementById('mobile-stats-btn');
const drawerNavStats = document.getElementById('drawer-nav-stats');
const statsModal = document.getElementById('stats-modal');
const closeStatsModalBtn = document.getElementById('close-stats-modal-btn');
const closeStatsBtn = document.getElementById('close-stats-btn');
const kpiCurrentStreak = document.getElementById('kpi-current-streak');
const kpiLongestStreak = document.getElementById('kpi-longest-streak');
const kpiRetentionRate = document.getElementById('kpi-retention-rate');
const kpiTotalReviews = document.getElementById('kpi-total-reviews');
const kpiTotalWords = document.getElementById('kpi-total-words');
const kpiTodayAdded = document.getElementById('kpi-today-added');
const kpiCefrLevel = document.getElementById('kpi-cefr-level');
const kpiCefrDesc = document.getElementById('kpi-cefr-desc');
const cntMastered = document.getElementById('cnt-mastered');
const cntReview = document.getElementById('cnt-review');
const cntLearning = document.getElementById('cnt-learning');
const cntNew = document.getElementById('cnt-new');
const cntLeeches = document.getElementById('cnt-leeches');
const progMastered = document.getElementById('prog-mastered');
const progReview = document.getElementById('prog-review');
const progLearning = document.getElementById('prog-learning');
const progNew = document.getElementById('prog-new');
const heatmapGrid = document.getElementById('heatmap-grid');
// -----------------------------------------------------------------------
// View & Queue Controls
// -----------------------------------------------------------------------
srsExitBtn.addEventListener('click', () => {
window.location.hash = '#dictionary';
});
srsMixBtn.addEventListener('click', () => {
srsSmartMix = !srsSmartMix;
srsMixLabel.textContent = srsSmartMix ? '🎲 Микс дня: ВКЛ' : '📚 Обычная очередь';
srsMixBtn.classList.toggle('text-success', srsSmartMix);
startSrsTrainingSession(false);
});
// Restore saved SRS mode if present
const savedSrsMode = localStorage.getItem('srs_active_mode');
if (savedSrsMode && ['flashcards', 'quiz', 'typing', 'cloze', 'dictation', 'listening'].includes(savedSrsMode)) {
srsCurrentMode = savedSrsMode;
srsTabs.forEach(t => {
t.classList.toggle('active', t.dataset.mode === savedSrsMode);
});
}
srsTabs.forEach(tab => {
tab.addEventListener('click', () => {
srsTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
srsCurrentMode = tab.dataset.mode;
localStorage.setItem('srs_active_mode', srsCurrentMode);
renderSrsCurrentCard();
});
});
srsDirBtn.addEventListener('click', () => {
srsDirection = srsDirection === 'en-ru' ? 'ru-en' : 'en-ru';
srsDirLabel.textContent = srsDirection === 'en-ru' ? 'EN ➔ RU' : 'RU ➔ EN';
renderSrsCurrentCard();
});
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
srsLoading.classList.remove('hidden');
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
hideAllSrsModeContainers();
srsSessionRatings = [];
srsCurrentIndex = 0;
let queue = [];
try {
const data = await api.srs.getQueue(forceAll, tagFilter);
queue = data.cards || [];
} catch (e) {
console.error('SRS Queue error:', e);
}
const cards = getAllCards();
if (queue.length === 0 && forceAll && cards.length > 0) {
queue = [...cards];
}
srsLoading.classList.add('hidden');
if (queue.length === 0 && srsCurrentMode !== 'listening') {
srsEmpty.classList.remove('hidden');
return;
}
srsQueue = queue;
renderSrsCurrentCard();
}
function closeSrsView() {
window.location.hash = '#dictionary';
}
srsTrainAllBtn.addEventListener('click', () => startSrsTrainingSession(true));
srsEmptyBackBtn.addEventListener('click', closeSrsView);
sumRepeatBtn.addEventListener('click', () => startSrsTrainingSession(true));
sumReturnBtn.addEventListener('click', closeSrsView);
function hideAllSrsModeContainers() {
srsModeFlashcards.classList.add('hidden');
srsModeQuiz.classList.add('hidden');
srsModeTyping.classList.add('hidden');
srsModeCloze.classList.add('hidden');
srsModeDictation.classList.add('hidden');
if (srsModeListening) srsModeListening.classList.add('hidden');
}
function renderSrsCurrentCard() {
if (srsCurrentMode === 'listening') {
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
hideAllSrsModeContainers();
renderListeningMode();
return;
}
if (srsCurrentIndex >= srsQueue.length) {
showSrsSummary();
return;
}
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
hideAllSrsModeContainers();
const total = srsQueue.length;
const currentNum = srsCurrentIndex + 1;
srsCounter.textContent = `Карточка ${currentNum} из ${total}`;
const pct = Math.round(((currentNum - 1) / total) * 100);
srsProgressFill.style.width = `${pct}%`;
const card = srsQueue[srsCurrentIndex];
if (srsCurrentMode === 'flashcards') {
renderFlashcardMode(card);
} else if (srsCurrentMode === 'quiz') {
renderQuizMode(card);
} else if (srsCurrentMode === 'typing') {
renderTypingMode(card);
} else if (srsCurrentMode === 'cloze') {
renderClozeMode(card);
} else if (srsCurrentMode === 'dictation') {
renderDictationMode(card);
}
}
// -----------------------------------------------------------------------
// Mode 1: Flashcards
// -----------------------------------------------------------------------
function renderFlashcardMode(card) {
srsModeFlashcards.classList.remove('hidden');
srsIsFlipped = false;
flashcardInner.classList.remove('is-flipped');
fcRatingBar.classList.add('hidden');
fcHintBox.classList.add('hidden');
fcFlipBtnWrap.style.display = 'block';
if (srsDirection === 'en-ru') {
fcFrontDirBadge.textContent = 'EN ➔ RU';
fcFrontMain.textContent = card.term;
fcFrontPos.textContent = card.part_of_speech || '';
fcFrontPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
if (card.phonetic) {
fcFrontPhonetic.textContent = card.phonetic;
fcFrontPhoneticWrap.style.display = 'flex';
} else {
fcFrontPhoneticWrap.style.display = 'none';
}
fcHintBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
fcHintBox.textContent = card.context_sentence ? `“${card.context_sentence}”` : '';
fcBackTerm.textContent = card.term;
fcBackTranslation.textContent = card.translation;
fcBackPos.textContent = card.part_of_speech || '';
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
} else {
fcFrontDirBadge.textContent = 'RU ➔ EN';
fcFrontMain.textContent = cleanPromptTranslation(card.translation);
fcFrontPos.textContent = card.part_of_speech || '';
fcFrontPhoneticWrap.style.display = 'none';
fcHintBtn.style.display = 'none';
fcBackTerm.textContent = card.term;
fcBackTranslation.textContent = card.translation;
fcBackPos.textContent = card.part_of_speech || '';
}
fcBackDef.textContent = card.definition_en || '';
fcBackDefBox.style.display = card.definition_en ? 'block' : 'none';
fcBackContext.textContent = card.context_sentence ? `“${card.context_sentence}”` : '';
fcBackContextBox.style.display = card.context_sentence ? 'block' : 'none';
fcBackNotes.textContent = card.notes || '';
fcBackNotesBox.style.display = card.notes ? 'block' : 'none';
if (fcFrontAudioBtn) fcFrontAudioBtn.style.display = 'inline-flex';
if (fcBackAudioBtn) fcBackAudioBtn.style.display = 'inline-flex';
if (fcBackContextAudioBtn) {
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
}
}
if (fcFrontAudioBtn) {
fcFrontAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
});
}
if (fcBackAudioBtn) {
fcBackAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
});
}
if (fcBackContextAudioBtn) {
fcBackContextAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card || !card.context_sentence) return;
const url = card.context_audio_url || `/api/tts?text=${encodeURIComponent(card.context_sentence)}&lang=en`;
playAudio(url);
});
}
function flipFlashcard() {
if (srsIsFlipped) return;
srsIsFlipped = true;
flashcardInner.classList.add('is-flipped');
fcRatingBar.classList.remove('hidden');
fcFlipBtnWrap.style.display = 'none';
}
flashcardFlipWrap.addEventListener('click', flipFlashcard);
fcFlipBtn.addEventListener('click', flipFlashcard);
fcHintBtn.addEventListener('click', (e) => {
e.stopPropagation();
fcHintBox.classList.toggle('hidden');
});
document.querySelectorAll('.btn-rate').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const rating = parseInt(btn.dataset.rating, 10);
await submitCardReview(rating);
});
});
async function submitCardReview(rating) {
const card = srsQueue[srsCurrentIndex];
if (!card) return;
srsSessionRatings.push(rating);
try {
await api.srs.submitReview(card.id, rating);
} catch (e) {
console.error('Review submission error:', e);
}
srsCurrentIndex++;
renderSrsCurrentCard();
}
// -----------------------------------------------------------------------
// Mode 2: Multiple Choice Quiz
// -----------------------------------------------------------------------
function renderQuizMode(card) {
srsModeQuiz.classList.remove('hidden');
quizAnswered = false;
quizFeedbackBox.classList.add('hidden');
quizDetailsBox.classList.add('hidden');
quizNextBar.classList.add('hidden');
quizPos.textContent = card.part_of_speech || '';
quizTermDisplay.textContent = card.term;
quizPhoneticDisplay.textContent = card.phonetic || '';
quizPhoneticDisplay.style.display = card.phonetic ? 'block' : 'none';
if (card.context_sentence) {
quizContextHint.textContent = `“${card.context_sentence}”`;
quizContextHint.classList.remove('hidden');
} else {
quizContextHint.classList.add('hidden');
}
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
const correctText = cleanPromptTranslation(card.translation);
const all = getAllCards();
const candidates = all
.filter(c => c.id !== card.id && c.translation && cleanPromptTranslation(c.translation) !== correctText)
.map(c => cleanPromptTranslation(c.translation));
shuffleArray(candidates);
let options = [correctText, ...candidates.slice(0, 3)];
while (options.length < 4) {
options.push(`Вариант ${options.length + 1}`);
}
options = shuffleArray(options);
quizCurrentOptions = options;
quizOptionsGrid.innerHTML = '';
options.forEach((optText, idx) => {
const btn = document.createElement('button');
btn.className = 'quiz-option-btn';
btn.innerHTML = `
<span class="quiz-opt-num">${idx + 1}</span>
<span class="quiz-opt-text">${escapeHtml(optText)}</span>
`;
btn.onclick = () => handleQuizSelection(optText, correctText, card, btn);
quizOptionsGrid.appendChild(btn);
});
}
async function handleQuizSelection(selectedText, correctText, card, clickedBtn) {
if (quizAnswered) return;
quizAnswered = true;
const isCorrect = selectedText === correctText;
const allOptionBtns = quizOptionsGrid.querySelectorAll('.quiz-option-btn');
allOptionBtns.forEach(btn => {
btn.disabled = true;
const text = btn.querySelector('.quiz-opt-text').textContent;
if (text === correctText) {
btn.classList.add('opt-correct');
} else if (btn === clickedBtn && !isCorrect) {
btn.classList.add('opt-wrong');
}
});
if (card.audio_url) playAudio(card.audio_url);
if (isCorrect) {
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
quizFeedbackBox.innerHTML = '🎉 <strong>Отлично! Правильный ответ.</strong>';
srsSessionRatings.push(4);
api.srs.submitReview(card.id, 4);
} else {
quizFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
quizFeedbackBox.innerHTML = `❌ <strong>Ошибка!</strong> Правильно: <em>${escapeHtml(correctText)}</em>`;
srsSessionRatings.push(1);
api.srs.submitReview(card.id, 1);
}
quizFeedbackBox.classList.remove('hidden');
if (card.definition_en || card.context_sentence) {
quizDetailsBox.innerHTML = `
${card.definition_en ? `<p><strong>Толкование:</strong> ${escapeHtml(card.definition_en)}</p>` : ''}
${card.context_sentence ? `<p><strong>Контекст:</strong> <em>“${escapeHtml(card.context_sentence)}”</em></p>` : ''}
`;
quizDetailsBox.classList.remove('hidden');
}
quizNextBar.classList.remove('hidden');
quizNextBtn.focus();
}
if (quizAudioBtn) {
quizAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
});
}
quizNextBtn.addEventListener('click', () => {
srsCurrentIndex++;
renderSrsCurrentCard();
});
// -----------------------------------------------------------------------
// Mode 3: Active Typing
// -----------------------------------------------------------------------
function renderTypingMode(card) {
srsModeTyping.classList.remove('hidden');
typingFeedbackBox.classList.add('hidden');
typingDetailsBox.classList.add('hidden');
typingRatingBar.classList.add('hidden');
typingPos.textContent = card.part_of_speech || '';
typingPromptRu.textContent = cleanPromptTranslation(card.translation);
if (card.context_sentence) {
typingContextHint.textContent = `Контекст: “${card.context_sentence}”`;
typingContextHint.classList.remove('hidden');
} else {
typingContextHint.classList.add('hidden');
}
typingInput.value = '';
typingInput.disabled = false;
typingInput.focus();
}
typingForm.addEventListener('submit', (e) => {
e.preventDefault();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const userInput = typingInput.value.trim().toLowerCase();
const target = card.term.trim().toLowerCase();
const isMatch = userInput === target;
if (card.audio_url) playAudio(card.audio_url);
typingInput.disabled = true;
if (isMatch) {
typingFeedbackBox.className = 'trainer-feedback-box feedback-correct';
typingFeedbackBox.innerHTML = `✅ <strong>Верно!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
} else {
typingFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
typingFeedbackBox.innerHTML = `❌ <strong>Не совсем:</strong> правильное написание — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
}
typingFeedbackBox.classList.remove('hidden');
renderDynamicRatingBar(typingRatingBar, isMatch ? 3 : 1);
});
// -----------------------------------------------------------------------
// Mode 4: Cloze Deletion
// -----------------------------------------------------------------------
function renderClozeMode(card) {
srsModeCloze.classList.remove('hidden');
clozeFeedbackBox.classList.add('hidden');
clozeDetailsBox.classList.add('hidden');
clozeRatingBar.classList.add('hidden');
clozePos.textContent = card.part_of_speech || '';
clozeTargetTranslation.textContent = cleanPromptTranslation(card.translation);
const context = card.context_sentence || `The word is ${card.term}.`;
const re = new RegExp(escapeRegex(card.term), 'gi');
const gapText = context.replace(re, '<span class="gap-placeholder">_____</span>');
clozeSentenceDisplay.innerHTML = gapText;
clozeInput.value = '';
clozeInput.disabled = false;
clozeInput.focus();
}
clozeForm.addEventListener('submit', (e) => {
e.preventDefault();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const userInput = clozeInput.value.trim().toLowerCase();
const target = card.term.trim().toLowerCase();
const isMatch = userInput === target;
if (card.audio_url) playAudio(card.audio_url);
clozeInput.disabled = true;
if (isMatch) {
clozeFeedbackBox.className = 'trainer-feedback-box feedback-correct';
clozeFeedbackBox.innerHTML = `✅ <strong>Верно!</strong> целевое слово: <strong>${escapeHtml(card.term)}</strong>`;
} else {
clozeFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
clozeFeedbackBox.innerHTML = `❌ Правильный ответ: <strong>${escapeHtml(card.term)}</strong>`;
}
clozeFeedbackBox.classList.remove('hidden');
renderDynamicRatingBar(clozeRatingBar, isMatch ? 3 : 1);
});
// -----------------------------------------------------------------------
// Mode 5: Dictation
// -----------------------------------------------------------------------
function renderDictationMode(card) {
srsModeDictation.classList.remove('hidden');
dictationFeedbackBox.classList.add('hidden');
dictationDetailsBox.classList.add('hidden');
dictationRatingBar.classList.add('hidden');
dictationHintText.classList.add('hidden');
dictationHintText.textContent = cleanPromptTranslation(card.translation);
dictationInput.value = '';
dictationInput.disabled = false;
const play = () => { if (card.audio_url) playAudio(card.audio_url); };
dictationBigPlayBtn.onclick = play;
dictationReplayBtn.onclick = play;
play();
dictationInput.focus();
}
dictationToggleHintBtn.addEventListener('click', () => {
dictationHintText.classList.toggle('hidden');
});
dictationForm.addEventListener('submit', (e) => {
e.preventDefault();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const userInput = dictationInput.value.trim().toLowerCase();
const target = card.term.trim().toLowerCase();
const isMatch = userInput === target;
dictationInput.disabled = true;
if (isMatch) {
dictationFeedbackBox.className = 'trainer-feedback-box feedback-correct';
dictationFeedbackBox.innerHTML = `✅ <strong>Идеально!</strong> <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''} — ${escapeHtml(card.translation)}`;
} else {
dictationFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
dictationFeedbackBox.innerHTML = `❌ <strong>Ошибка спеллинга:</strong> правильное слово — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
}
dictationFeedbackBox.classList.remove('hidden');
renderDynamicRatingBar(dictationRatingBar, isMatch ? 3 : 1);
});
function renderDynamicRatingBar(container, defaultRating) {
container.innerHTML = `
<button class="btn-rate btn-rate-1" data-rating="1"><span class="rate-key">[1]</span> Снова</button>
<button class="btn-rate btn-rate-2" data-rating="2"><span class="rate-key">[2]</span> Трудно</button>
<button class="btn-rate btn-rate-3" data-rating="3"><span class="rate-key">[3]</span> Хорошо</button>
<button class="btn-rate btn-rate-4" data-rating="4"><span class="rate-key">[4]</span> Легко</button>
`;
container.classList.remove('hidden');
container.querySelectorAll('.btn-rate').forEach(btn => {
btn.onclick = async () => {
const r = parseInt(btn.dataset.rating, 10);
await submitCardReview(r);
};
});
}
// -----------------------------------------------------------------------
// Mode 6: Listening Comprehension
// -----------------------------------------------------------------------
function renderListeningMode() {
if (!srsModeListening) return;
srsModeListening.classList.remove('hidden');
srsCounter.textContent = `📻 Аудирование: решено ${listeningCompletedCount}`;
srsProgressFill.style.width = '100%';
if (!currentListeningExercise) {
loadListeningExercise();
}
}
async function loadListeningExercise(forceNew = false) {
if (!srsModeListening) return;
listeningAnswered = false;
if (listeningFeedbackBox) {
listeningFeedbackBox.classList.add('hidden');
listeningFeedbackBox.innerHTML = '';
}
if (listeningRevealedBox) listeningRevealedBox.classList.add('hidden');
if (listeningHintText) {
listeningHintText.classList.add('hidden');
listeningHintText.textContent = '';
}
if (listeningInput) {
listeningInput.value = '';
listeningInput.disabled = false;
}
if (listeningSubmitBtn) listeningSubmitBtn.disabled = false;
if (listeningTitle) listeningTitle.textContent = 'Загрузка аудио-ситуации...';
const topic = listeningTopicSelect ? listeningTopicSelect.value : 'all';
const voice = listeningVoiceSelect ? listeningVoiceSelect.value : (localStorage.getItem('notes_tts_voice_en') || 'en_US-lessac');
try {
const data = await api.listening.getExercise(topic, 'B1', voice);
currentListeningExercise = data;
if (listeningTitle) listeningTitle.textContent = data.title || 'Ситуация для аудирования';
if (listeningLevelBadge) {
listeningLevelBadge.textContent = data.level || 'B1';
listeningLevelBadge.className = `level-badge badge-${(data.level || 'b1').toLowerCase()}`;
}
if (listeningTopicBadge) {
listeningTopicBadge.textContent = (data.topic || 'tech').toUpperCase();
}
if (data.hint && listeningToggleHintBtn && listeningHintText) {
listeningToggleHintBtn.classList.remove('hidden');
listeningHintText.textContent = data.hint;
} else if (listeningToggleHintBtn) {
listeningToggleHintBtn.classList.add('hidden');
}
// Smooth focus on input
setTimeout(() => {
if (listeningInput && !srsModeListening.classList.contains('hidden')) {
listeningInput.focus();
}
}, 50);
// Auto-play the story audio
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
if (data.story_audio_url) {
playAudio(data.story_audio_url, speed);
}
} catch (e) {
console.error('Failed to load listening exercise:', e);
if (listeningTitle) listeningTitle.textContent = 'Не удалось загрузить ситуацию. Нажмите "Другая история".';
showToast('Ошибка загрузки аудирования', 'error');
}
}
if (listeningPlayStoryBtn) {
listeningPlayStoryBtn.addEventListener('click', () => {
if (!currentListeningExercise || !currentListeningExercise.story_audio_url) return;
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
playAudio(currentListeningExercise.story_audio_url, speed);
});
}
if (listeningPlayQuestionBtn) {
listeningPlayQuestionBtn.addEventListener('click', () => {
if (!currentListeningExercise || !currentListeningExercise.question_audio_url) return;
const speed = listeningSpeedSelect ? parseFloat(listeningSpeedSelect.value) || 1.0 : 1.0;
playAudio(currentListeningExercise.question_audio_url, speed);
});
}
if (listeningToggleHintBtn) {
listeningToggleHintBtn.addEventListener('click', () => {
if (listeningHintText) listeningHintText.classList.toggle('hidden');
});
}
if (listeningNewScenarioBtn) {
listeningNewScenarioBtn.addEventListener('click', () => {
loadListeningExercise(true);
});
}
if (listeningTopicSelect) {
listeningTopicSelect.addEventListener('change', () => {
loadListeningExercise(true);
});
}
if (listeningVoiceSelect) {
listeningVoiceSelect.value = localStorage.getItem('notes_tts_voice_en') || 'en_US-lessac';
listeningVoiceSelect.addEventListener('change', () => {
localStorage.setItem('notes_tts_voice_en', listeningVoiceSelect.value);
loadListeningExercise(true);
});
}
if (listeningForm) {
listeningForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (listeningAnswered || !currentListeningExercise) return;
const val = (listeningInput ? listeningInput.value : '').trim();
if (!val) {
showToast('Пожалуйста, напишите ваш ответ', 'info');
if (listeningInput) listeningInput.focus();
return;
}
listeningAnswered = true;
if (listeningInput) listeningInput.disabled = true;
if (listeningSubmitBtn) listeningSubmitBtn.disabled = true;
try {
const res = await api.listening.checkAnswer(
currentListeningExercise.id,
val,
currentListeningExercise.story,
currentListeningExercise.ideal_answer || ''
);
if (listeningFeedbackBox) {
if (res.is_correct) {
listeningFeedbackBox.className = 'trainer-feedback-box feedback-correct';
listeningFeedbackBox.innerHTML = `🎉 <strong>${escapeHtml(res.feedback)}</strong>`;
srsSessionRatings.push(4);
} else {
listeningFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
listeningFeedbackBox.innerHTML = `<strong>${escapeHtml(res.feedback)}</strong>`;
srsSessionRatings.push(2);
}
listeningFeedbackBox.classList.remove('hidden');
}
// Reveal details
if (listeningIdealAnswer) listeningIdealAnswer.textContent = res.ideal_answer || '';
if (listeningRevealedQuestion) listeningRevealedQuestion.textContent = res.question || currentListeningExercise.question || '';
if (listeningRevealedQuestionRu) listeningRevealedQuestionRu.textContent = res.question_ru || currentListeningExercise.question_ru || '';
if (listeningRevealedStory) listeningRevealedStory.textContent = res.story || currentListeningExercise.story || '';
if (listeningRevealedStoryRu) listeningRevealedStoryRu.textContent = res.story_ru || currentListeningExercise.story_ru || '';
if (listeningRevealedBox) listeningRevealedBox.classList.remove('hidden');
listeningCompletedCount++;
srsCounter.textContent = `📻 Аудирование: решено ${listeningCompletedCount}`;
if (listeningNextBtn) listeningNextBtn.focus();
} catch (err) {
console.error('Listening answer check error:', err);
if (listeningInput) listeningInput.disabled = false;
if (listeningSubmitBtn) listeningSubmitBtn.disabled = false;
listeningAnswered = false;
showToast('Ошибка проверки ответа', 'error');
}
});
}
if (listeningNextBtn) {
listeningNextBtn.addEventListener('click', () => {
loadListeningExercise(true);
});
}
function showSrsSummary() {
hideAllSrsModeContainers();
srsSummary.classList.remove('hidden');
srsProgressFill.style.width = '100%';
srsCounter.textContent = 'Тренировка завершена!';
const total = srsSessionRatings.length;
sumCount.textContent = total;
const goodCount = srsSessionRatings.filter(r => r >= 3).length;
const retention = total > 0 ? Math.round((goodCount / total) * 100) : 100;
sumRetention.textContent = `${retention}%`;
}
// Hotkeys handler
document.addEventListener('keydown', (e) => {
if (!srsView.classList.contains('hidden')) {
if (srsCurrentMode === 'flashcards') {
if (e.code === 'Space' && !srsIsFlipped) {
e.preventDefault();
flipFlashcard();
} else if (srsIsFlipped && ['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
submitCardReview(parseInt(e.key, 10));
}
} else if (srsCurrentMode === 'quiz' && !quizAnswered) {
if (['1', '2', '3', '4'].includes(e.key)) {
const idx = parseInt(e.key, 10) - 1;
const btns = quizOptionsGrid.querySelectorAll('.quiz-option-btn');
if (btns[idx]) {
e.preventDefault();
btns[idx].click();
}
}
} else if (srsCurrentMode === 'quiz' && quizAnswered) {
if (e.key === 'Enter' || e.code === 'Space') {
e.preventDefault();
quizNextBtn.click();
}
} else if (srsCurrentMode === 'listening') {
if (listeningAnswered) {
if (e.key === 'Enter') {
e.preventDefault();
if (listeningNextBtn) listeningNextBtn.click();
}
} else {
if (document.activeElement !== listeningInput && e.code === 'Space') {
e.preventDefault();
if (listeningPlayStoryBtn) listeningPlayStoryBtn.click();
}
}
}
}
});
// -----------------------------------------------------------------------
// Analytics Dashboard & Heatmap Modal
// -----------------------------------------------------------------------
if (statsOpenBtn) statsOpenBtn.addEventListener('click', openStatsDashboard);
if (mobileStatsBtn) mobileStatsBtn.addEventListener('click', openStatsDashboard);
if (drawerNavStats) drawerNavStats.addEventListener('click', openStatsDashboard);
closeStatsModalBtn.addEventListener('click', () => closeModal(statsModal));
closeStatsBtn.addEventListener('click', () => closeModal(statsModal));
async function openStatsDashboard() {
openModal(statsModal);
await Promise.all([loadStatsKpis(), loadActivityHeatmap()]);
}
async function loadStatsKpis() {
try {
const s = await api.stats.getSrsStats();
if (s) {
kpiCurrentStreak.textContent = `🔥 ${s.streak_days}`;
kpiLongestStreak.textContent = `Рекорд: ${s.longest_streak} дн.`;
kpiRetentionRate.textContent = `${s.retention_rate}%`;
kpiTotalReviews.textContent = `Всего повторений: ${s.total_reviews}`;
kpiTotalWords.textContent = s.total_cards;
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
if (s.cefr) {
kpiCefrLevel.textContent = s.cefr.level;
kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`;
} else {
const count = s.total_cards;
let lvl = 'A1';
let desc = 'Базовый словарный запас';
if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
kpiCefrLevel.textContent = lvl;
kpiCefrDesc.textContent = desc;
}
cntMastered.textContent = s.mastered;
cntReview.textContent = s.review;
cntLearning.textContent = s.learning;
cntNew.textContent = s.new_cards;
cntLeeches.textContent = s.leech_count;
const total = s.total_cards || 1;
progMastered.style.width = `${(s.mastered / total) * 100}%`;
progReview.style.width = `${(s.review / total) * 100}%`;
progLearning.style.width = `${(s.learning / total) * 100}%`;
progNew.style.width = `${(s.new_cards / total) * 100}%`;
}
} catch (e) {
console.error('Stats load error:', e);
}
}
async function loadActivityHeatmap() {
try {
const data = await api.stats.getHeatmap(180);
renderHeatmap(data.days);
} catch (e) {
console.error('Heatmap load error:', e);
}
}
function renderHeatmap(days) {
heatmapGrid.innerHTML = '';
if (!days || days.length === 0) return;
days.forEach(d => {
const cell = document.createElement('div');
cell.className = `heatmap-cell lvl-${d.level}`;
cell.title = `${d.date}: ${d.count} повторений / тестов`;
heatmapGrid.appendChild(cell);
});
}
initSrs.startSession = startSrsTrainingSession;
initSrs.openStats = openStatsDashboard;
}
export function startSrsTrainingSession(forceAll = false, tagFilter = null) {
if (initSrs.startSession) {
return initSrs.startSession(forceAll, tagFilter);
}
}
export function openStatsDashboard() {
if (initSrs.openStats) {
return initSrs.openStats();
}
}