Files

771 lines
34 KiB
JavaScript
Raw Permalink 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.
// =======================================================================
// CEFR Level Testing & Automated Gap Ingestion Module (Native ES Module)
// Oxford 3000 / 5000 Benchmark Testing Engine
// =======================================================================
import { api } from './api.js';
import { showToast, escapeHtml, playTextSpeech } from './ui.js';
let currentLevel = 'A1';
let currentMode = 'all';
let currentQuiz = [];
let currentQuestionIdx = 0;
let isAnswered = false;
let hintUsedThisQuestion = false;
const sessionStats = {
total: 0,
correct: 0,
mistakes: 0,
ingested: 0,
score: 0,
hintsUsed: 0,
};
// -----------------------------------------------------------------------
// Initializer
// -----------------------------------------------------------------------
export function initCefr() {
const backBtn = document.getElementById('cefr-back-btn');
const nextBtn = document.getElementById('cefr-next-btn');
const sumRepeatBtn = document.getElementById('cefr-sum-repeat-btn');
const sumLevelsBtn = document.getElementById('cefr-sum-levels-btn');
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
showCefrScreen('overview');
window.location.hash = '#cefr';
loadCefrOverview();
});
}
if (nextBtn) {
nextBtn.addEventListener('click', () => {
advanceQuestion();
});
}
if (sumRepeatBtn) {
sumRepeatBtn.addEventListener('click', () => {
startCefrQuiz(currentLevel, currentMode);
});
}
if (sumLevelsBtn) {
sumLevelsBtn.addEventListener('click', () => {
showCefrScreen('overview');
window.location.hash = '#cefr';
loadCefrOverview();
});
}
if (sumSrsBtn) {
sumSrsBtn.addEventListener('click', () => {
const levelTag = `level-${currentLevel.toLowerCase()}`;
window.location.hash = `#srs/${encodeURIComponent(levelTag)}`;
});
}
// Mine books modal setup
const mineBtn = document.getElementById('cefr-mine-books-btn');
const mineModal = document.getElementById('mine-books-modal');
const closeMineBtn = document.getElementById('close-mine-books-modal-btn');
const cancelMineBtn = document.getElementById('cancel-mine-books-btn');
const mineForm = document.getElementById('mine-books-form');
const bookSelect = document.getElementById('mine-books-select');
if (mineBtn && mineModal) {
mineBtn.addEventListener('click', async () => {
if (bookSelect) {
try {
const books = await api.books.getAll();
let optionsHtml = '<option value="">📚 Все книги библиотеки</option>';
books.forEach(b => {
optionsHtml += `<option value="${b.id}">📖 ${escapeHtml(b.title)}${b.author ? ` (${escapeHtml(b.author)})` : ''}</option>`;
});
bookSelect.innerHTML = optionsHtml;
} catch (e) {
console.warn('Failed to load books for select:', e);
}
}
mineModal.classList.remove('hidden');
});
const closeMineModal = () => mineModal.classList.add('hidden');
if (closeMineBtn) closeMineBtn.addEventListener('click', closeMineModal);
if (cancelMineBtn) cancelMineBtn.addEventListener('click', closeMineModal);
if (mineForm) {
mineForm.addEventListener('submit', async (e) => {
e.preventDefault();
const bookIdVal = document.getElementById('mine-books-select')?.value;
const bookId = bookIdVal ? parseInt(bookIdVal, 10) : null;
const level = document.getElementById('mine-level-select')?.value || 'ALL';
const count = parseInt(document.getElementById('mine-count-input')?.value || '15', 10);
closeMineModal();
try {
const res = await api.tasks.mineBooks(bookId, level, count);
showToast(res.message || 'Сборка тестов запущена в фоне', 'info');
if (res.task_id) {
startTaskPolling(res.task_id);
} else {
checkActiveMiningTask();
}
} catch (err) {
showToast(`Ошибка запуска: ${err.message}`, 'error');
}
});
}
}
// Keyboard navigation (1-4 for options, Space/Enter for next)
window.addEventListener('keydown', (e) => {
const cefrView = document.getElementById('cefr-view');
const quizScreen = document.getElementById('cefr-quiz-screen');
if (!cefrView || cefrView.classList.contains('hidden')) return;
if (!quizScreen || quizScreen.classList.contains('hidden')) return;
// Ignore if user is inside an input/textarea
if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return;
if (!isAnswered) {
if (['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
const idx = parseInt(e.key, 10) - 1;
const btns = document.querySelectorAll('.cefr-opt-btn');
if (btns[idx]) btns[idx].click();
}
} else {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
advanceQuestion();
}
}
});
}
// -----------------------------------------------------------------------
// Background Tasks Polling & Banner Controls
// -----------------------------------------------------------------------
let activeTaskPollTimer = null;
export async function checkActiveMiningTask() {
try {
const res = await api.tasks.getActive('mine_books');
const task = res.task;
if (task && ['pending', 'running'].includes(task.status)) {
renderTaskBanner(task);
startTaskPolling(task.id);
} else {
hideTaskBanner();
}
} catch (err) {
console.warn('Failed to check active task:', err);
}
}
function startTaskPolling(taskId) {
if (activeTaskPollTimer) clearInterval(activeTaskPollTimer);
activeTaskPollTimer = setInterval(async () => {
try {
const res = await api.tasks.getStatus(taskId);
const task = res.task;
if (!task) {
clearInterval(activeTaskPollTimer);
activeTaskPollTimer = null;
hideTaskBanner();
return;
}
if (task.status === 'completed') {
clearInterval(activeTaskPollTimer);
activeTaskPollTimer = null;
hideTaskBanner();
showToast(task.message || 'Сборка тестов завершена!', 'success');
loadCefrOverview();
} else if (task.status === 'cancelled') {
clearInterval(activeTaskPollTimer);
activeTaskPollTimer = null;
hideTaskBanner();
showToast('Сборка тестов отменена', 'info');
} else if (task.status === 'failed') {
clearInterval(activeTaskPollTimer);
activeTaskPollTimer = null;
hideTaskBanner();
showToast(`Ошибка сборки: ${task.error_message || task.message}`, 'error');
} else {
renderTaskBanner(task);
}
} catch (err) {
console.warn('Poll error:', err);
}
}, 1500);
}
function renderTaskBanner(task) {
const banner = document.getElementById('cefr-task-banner');
if (!banner) return;
const pct = task.progress_percent || 0;
banner.innerHTML = `
<div class="task-banner-inner">
<div class="task-banner-content">
<div class="task-banner-title">
<span class="spinner-sm"></span>
<strong>⚡ Фоновая сборка тестов из книг</strong>
<span class="task-banner-counts">${task.progress_current} / ${task.progress_total} (${pct}%)</span>
</div>
<div class="task-banner-msg">${escapeHtml(task.message || 'Анализ предложений...')}</div>
<div class="progress-bar-wrap" style="height: 6px; margin-top: 6px;">
<div class="progress-bar-fill level-badge-b1" style="width: ${pct}%"></div>
</div>
</div>
<div class="task-banner-action">
<button type="button" class="btn btn-xs btn-outline task-cancel-btn" data-task-id="${task.id}">Отмена</button>
</div>
</div>
`;
banner.classList.remove('hidden');
banner.querySelector('.task-cancel-btn')?.addEventListener('click', async () => {
try {
await api.tasks.cancel(task.id);
showToast('Запрос на отмену отправлен...', 'info');
} catch (e) {
showToast('Не удалось отменить задачу', 'error');
}
});
}
function hideTaskBanner() {
const banner = document.getElementById('cefr-task-banner');
if (banner) {
banner.classList.add('hidden');
banner.innerHTML = '';
}
}
// -----------------------------------------------------------------------
// Screen Switcher inside #cefr-view
// -----------------------------------------------------------------------
function showCefrScreen(screenName) {
const overview = document.getElementById('cefr-overview-screen');
const quiz = document.getElementById('cefr-quiz-screen');
const summary = document.getElementById('cefr-summary-screen');
if (overview) overview.classList.toggle('hidden', screenName !== 'overview');
if (quiz) quiz.classList.toggle('hidden', screenName !== 'quiz');
if (summary) summary.classList.toggle('hidden', screenName !== 'summary');
}
// -----------------------------------------------------------------------
// Load & Render Overview
// -----------------------------------------------------------------------
export async function loadCefrOverview() {
showCefrScreen('overview');
checkActiveMiningTask();
const grid = document.getElementById('cefr-levels-grid');
if (!grid) return;
grid.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка уровней...</div>';
try {
const data = await api.cefr.getLevels();
const levels = data.levels || [];
renderLevelsGrid(levels);
} catch (err) {
grid.innerHTML = `<div class="empty-state"><p>Ошибка загрузки: ${escapeHtml(err.message)}</p></div>`;
}
}
function renderLevelsGrid(levels) {
const grid = document.getElementById('cefr-levels-grid');
if (!grid) return;
if (!levels.length) {
grid.innerHTML = '<div class="empty-state"><p>Нет доступных уровней</p></div>';
return;
}
grid.innerHTML = levels.map(lvl => {
const pct = lvl.progress_percent || 0;
const hasMistakes = lvl.in_learning_count > 0;
const colorClass = `level-badge-${lvl.level.toLowerCase()}`;
return `
<div class="cefr-level-card" data-level="${lvl.level}">
<div class="level-card-header">
<div class="level-pill ${colorClass}">
<span class="level-code">${escapeHtml(lvl.level)}</span>
</div>
<div class="level-header-text">
<h3 class="level-title">${escapeHtml(lvl.title)}</h3>
<span class="level-meta-sub">${lvl.total_questions} эталонных слов</span>
</div>
</div>
<div class="level-card-progress">
<div class="progress-bar-wrap">
<div class="progress-bar-fill ${colorClass}" style="width: ${pct}%"></div>
</div>
<div class="progress-labels-row">
<span class="pct-text">${pct}% освоено</span>
<span class="counts-text">${lvl.mastered_count} / ${lvl.total_questions}</span>
</div>
</div>
<div class="level-card-metrics">
<div class="mini-metric">
<span class="m-val text-success">✓ ${lvl.mastered_count}</span>
<span class="m-lbl">Освоено</span>
</div>
<div class="mini-metric">
<span class="m-val text-warning">⚠️ ${lvl.in_learning_count}</span>
<span class="m-lbl">Ошибок</span>
</div>
<div class="mini-metric">
<span class="m-val text-primary">🎓 ${lvl.srs_cards_count}</span>
<span class="m-lbl">В словаре SRS</span>
</div>
</div>
<div class="level-card-actions">
<button type="button" class="btn btn-primary btn-block cefr-start-btn" data-level="${lvl.level}" data-mode="all">
🎯 Начать тест (10)
</button>
${hasMistakes ? `
<button type="button" class="btn btn-outline btn-block cefr-mistakes-btn" data-level="${lvl.level}" data-mode="mistakes">
⚠️ Работа над ошибками (${lvl.in_learning_count})
</button>
` : ''}
<button type="button" class="btn btn-xs btn-ghost cefr-reset-btn" data-level="${lvl.level}" title="Сбросить прогресс уровня">
🔄 Сброс
</button>
</div>
</div>
`;
}).join('');
// Attach button listeners
grid.querySelectorAll('.cefr-start-btn').forEach(btn => {
btn.addEventListener('click', () => {
const lvl = btn.dataset.level;
const mode = btn.dataset.mode || 'all';
window.location.hash = `#cefr/${encodeURIComponent(lvl)}`;
startCefrQuiz(lvl, mode);
});
});
grid.querySelectorAll('.cefr-mistakes-btn').forEach(btn => {
btn.addEventListener('click', () => {
const lvl = btn.dataset.level;
window.location.hash = `#cefr/${encodeURIComponent(lvl)}/mistakes`;
startCefrQuiz(lvl, 'mistakes');
});
});
grid.querySelectorAll('.cefr-reset-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const lvl = btn.dataset.level;
if (confirm(`Вы действительно хотите сбросить прогресс прохождения уровня ${lvl}?`)) {
try {
await api.cefr.resetLevel(lvl);
showToast(`Прогресс уровня ${lvl} сброшен`, 'info');
loadCefrOverview();
} catch (e) {
showToast('Ошибка сброса прогресса', 'error');
}
}
});
});
}
// -----------------------------------------------------------------------
// Start Quiz Session
// -----------------------------------------------------------------------
export async function startCefrQuiz(level = 'A1', mode = 'all') {
currentLevel = level.toUpperCase();
currentMode = mode;
currentQuestionIdx = 0;
isAnswered = false;
sessionStats.total = 0;
sessionStats.correct = 0;
sessionStats.mistakes = 0;
sessionStats.ingested = 0;
sessionStats.score = 0;
sessionStats.hintsUsed = 0;
showCefrScreen('quiz');
const cardContainer = document.getElementById('cefr-cloze-card');
if (cardContainer) {
cardContainer.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка теста...</div>';
}
try {
const resp = await api.cefr.getQuiz(currentLevel, 10, currentMode);
currentQuiz = resp.quiz || [];
sessionStats.total = currentQuiz.length;
if (!currentQuiz.length) {
if (currentMode === 'mistakes') {
showToast(`На уровне ${currentLevel} нет неисправленных ошибок! 🏆`, 'success');
showCefrScreen('overview');
loadCefrOverview();
return;
}
showToast('Нет доступных вопросов для этого уровня', 'warning');
showCefrScreen('overview');
loadCefrOverview();
return;
}
renderCurrentQuestion();
} catch (err) {
showToast(`Ошибка загрузки вопросов: ${err.message}`, 'error');
showCefrScreen('overview');
loadCefrOverview();
}
}
// -----------------------------------------------------------------------
// Render Active Question
// -----------------------------------------------------------------------
function renderCurrentQuestion() {
if (currentQuestionIdx >= currentQuiz.length) {
renderQuizSummary();
return;
}
isAnswered = false;
const q = currentQuiz[currentQuestionIdx];
// Header info
const levelBadge = document.getElementById('cefr-quiz-level-badge');
const counterBadge = document.getElementById('cefr-quiz-counter');
const progressBar = document.getElementById('cefr-quiz-progress-bar');
const nextBar = document.getElementById('cefr-next-bar');
if (levelBadge) {
levelBadge.textContent = `Уровень ${q.level}`;
levelBadge.className = `level-pill level-badge-${q.level.toLowerCase()}`;
}
if (counterBadge) {
counterBadge.textContent = `${currentQuestionIdx + 1} / ${currentQuiz.length}`;
}
if (progressBar) {
const pct = ((currentQuestionIdx) / currentQuiz.length) * 100;
progressBar.style.width = `${pct}%`;
}
if (nextBar) nextBar.classList.add('hidden');
const cardContainer = document.getElementById('cefr-cloze-card');
if (!cardContainer) return;
hintUsedThisQuestion = false;
// Stylize the sentence blank: replace ___ with a visible blank node
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
cardContainer.innerHTML = `
<div class="cefr-cloze-header">
<span class="cefr-mode-tag">🧩 Cloze Tap (1-тап выбор)</span>
${q.pos ? `<span class="badge-pos">${escapeHtml(q.pos)}</span>` : ''}
${q.source_book_title ? `<span class="cefr-book-badge" title="${escapeHtml(q.source_chapter_title || '')}">📖 «${escapeHtml(q.source_book_title)}»${q.source_chapter_title ? ` (${escapeHtml(q.source_chapter_title)})` : ''}</span>` : ''}
</div>
<div class="cefr-sentence-wrap">
<p class="cefr-sentence-text">${formattedSentence}</p>
</div>
<div class="cefr-clue-box" id="cefr-clue-box">
<button type="button" class="btn btn-xs btn-outline cefr-hint-btn" id="cefr-hint-btn" title="Показать подсказку с переводом слова (-0.2 балла)">
💡 Подсказка (-0.2 балла)
</button>
<div id="cefr-hint-content" class="cefr-hint-content hidden">
<span class="clue-icon">💡</span>
<span class="clue-label">Значение:</span>
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
</div>
</div>
<div class="cefr-options-grid" id="cefr-options-grid">
${q.options.map((opt, idx) => `
<button type="button" class="cefr-opt-btn" data-option="${escapeHtml(opt)}">
<span class="opt-num">[${idx + 1}]</span>
<span class="opt-text">${escapeHtml(opt)}</span>
</button>
`).join('')}
</div>
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
`;
// Hook up hint toggle button
const hintBtn = cardContainer.querySelector('#cefr-hint-btn');
const hintContent = cardContainer.querySelector('#cefr-hint-content');
if (hintBtn && hintContent) {
hintBtn.addEventListener('click', () => {
hintUsedThisQuestion = true;
hintBtn.classList.add('hidden');
hintContent.classList.remove('hidden');
});
}
// Attach click listeners to option buttons
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
btn.addEventListener('click', () => {
const opt = btn.dataset.option;
handleOptionTap(q.id, opt, btn);
});
});
}
// -----------------------------------------------------------------------
// Handle Option Selection (1-tap Cloze Tap)
// -----------------------------------------------------------------------
async function handleOptionTap(questionId, selectedOption, clickedBtn) {
if (isAnswered) return;
isAnswered = true;
const q = currentQuiz[currentQuestionIdx];
const sentence = q ? q.sentence : '';
// Disable all options
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
b.disabled = true;
});
const feedbackBox = document.getElementById('cefr-feedback-box');
const nextBar = document.getElementById('cefr-next-bar');
const blankTarget = document.getElementById('cefr-blank-target');
try {
const res = await api.cefr.submitAnswer(questionId, selectedOption);
if (res.correct) {
sessionStats.correct++;
const pts = hintUsedThisQuestion ? 0.8 : 1.0;
sessionStats.score += pts;
if (hintUsedThisQuestion) sessionStats.hintsUsed++;
clickedBtn.classList.add('opt-correct');
if (blankTarget) {
blankTarget.textContent = res.correct_option;
blankTarget.classList.add('blank-correct');
}
if (feedbackBox) {
feedbackBox.className = 'cefr-feedback-box feedback-correct';
feedbackBox.innerHTML = `
<div class="fb-title">✓ Верно! ${pts === 0.8 ? 'Получено: <strong>+0.8 балла</strong> (с подсказкой)' : 'Получено: <strong>+1.0 балл</strong>'}</div>
<div class="fb-meta" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
<span class="audio-ctrl-group">
<button type="button" class="icon-btn audio-xs-btn play-cefr-word-audio-btn" title="Озвучить слово (1.0x)">🔊</button>
<button type="button" class="icon-btn audio-xs-btn audio-slow-btn play-cefr-word-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
</span>
</div>
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
${renderOptionsBreakdown(res.options_breakdown)}
`;
feedbackBox.classList.remove('hidden');
feedbackBox.querySelector('.play-cefr-word-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(res.correct_option, 'en', 1.0);
});
feedbackBox.querySelector('.play-cefr-word-slow-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(res.correct_option, 'en', 0.75);
});
attachBreakdownListeners(feedbackBox, sentence);
}
} else {
sessionStats.mistakes++;
// Two words were ingested: the correct target word and the clicked distractor
sessionStats.ingested += 2;
// Automatically reveal clue on mistake so user sees the meaning
const hBtn = document.getElementById('cefr-hint-btn');
const hCnt = document.getElementById('cefr-hint-content');
if (hBtn) hBtn.classList.add('hidden');
if (hCnt) hCnt.classList.remove('hidden');
clickedBtn.classList.add('opt-wrong');
// Highlight the true correct option
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
if (b.dataset.option.toLowerCase() === res.correct_option.toLowerCase()) {
b.classList.add('opt-correct-revealed');
}
});
if (blankTarget) {
blankTarget.textContent = res.correct_option;
blankTarget.classList.add('blank-wrong-revealed');
}
if (feedbackBox) {
feedbackBox.className = 'cefr-feedback-box feedback-wrong';
feedbackBox.innerHTML = `
<div class="fb-title">✗ Неверно. Правильное слово: <strong>${escapeHtml(res.correct_option)}</strong></div>
<div class="fb-meta">
<span class="fb-trans">${escapeHtml(res.translation || '')}</span>
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
</div>
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
<div class="cefr-dual-ingested-box">
<div class="dual-ingested-header">
<span class="badge-icon">📥</span>
<strong>В ваш словарь SRS («В процессе») добавлены оба слова:</strong>
</div>
<div class="dual-ingested-chips">
<div class="ingested-chip chip-target">
<span class="chip-label">Верный ответ:</span>
<strong>${escapeHtml(res.correct_option)}</strong> — ${escapeHtml(res.translation || '')}
</div>
<div class="ingested-chip chip-chosen">
<span class="chip-label">Ваш выбор:</span>
<strong>${escapeHtml(res.selected_option)}</strong> — ${escapeHtml(res.selected_translation || '')}
</div>
</div>
</div>
${renderOptionsBreakdown(res.options_breakdown)}
`;
feedbackBox.classList.remove('hidden');
attachBreakdownListeners(feedbackBox, sentence);
}
}
if (nextBar) nextBar.classList.remove('hidden');
} catch (err) {
showToast(`Ошибка проверки ответа: ${err.message}`, 'error');
if (nextBar) nextBar.classList.remove('hidden');
}
}
// -----------------------------------------------------------------------
// Advance to Next Question
// -----------------------------------------------------------------------
function advanceQuestion() {
currentQuestionIdx++;
renderCurrentQuestion();
}
// -----------------------------------------------------------------------
// Render Quiz Summary
// -----------------------------------------------------------------------
function renderQuizSummary() {
showCefrScreen('summary');
const total = sessionStats.total || currentQuiz.length || 1;
const correct = sessionStats.correct;
const mistakes = sessionStats.mistakes;
const ingested = sessionStats.ingested;
const earnedScore = Math.round(sessionStats.score * 10) / 10;
const pct = Math.round((earnedScore / total) * 100);
const sumScore = document.getElementById('cefr-sum-score');
const sumLead = document.getElementById('cefr-sum-lead');
const sumCorrect = document.getElementById('cefr-sum-correct');
const sumMistakes = document.getElementById('cefr-sum-mistakes');
const sumIngested = document.getElementById('cefr-sum-ingested');
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
if (sumScore) sumScore.textContent = `${pct}%`;
if (sumCorrect) sumCorrect.textContent = `${earnedScore} / ${total} баллов (${correct} верных)`;
if (sumMistakes) sumMistakes.innerHTML = `${mistakes} ${sessionStats.hintsUsed > 0 ? `<span style="font-size:12px;opacity:0.8;">(подсказок: ${sessionStats.hintsUsed})</span>` : ''}`;
if (sumIngested) sumIngested.textContent = ingested;
if (sumLead) {
if (pct >= 90) {
sumLead.textContent = `Великолепно! Уровень ${currentLevel} освоен на высочайшем уровне! 🌟`;
} else if (pct >= 70) {
sumLead.textContent = `Хороший результат! Большинство ключевых слов уровня ${currentLevel} вам знакомы. 👍`;
} else {
sumLead.textContent = `Тренировка завершена. Все ошибки бережно сохранены в ваш SRS для закрепления. 💪`;
}
}
if (sumSrsBtn) {
sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0);
}
}
// -----------------------------------------------------------------------
// Render Options Breakdown (All 4 options with 1-tap SRS Ingestion)
// -----------------------------------------------------------------------
function renderOptionsBreakdown(breakdown) {
if (!breakdown || breakdown.length === 0) return '';
return `
<div class="cefr-words-breakdown">
<div class="words-breakdown-header">
<span class="wb-title"><i class="ph-books"></i> Слова из этого вопроса:</span>
<span class="wb-hint">Добавьте в словарь слова, перевод которых хотите запомнить</span>
</div>
<div class="words-breakdown-grid">
${breakdown.map(b => {
const isTarget = b.is_correct;
const wasChosenMistake = b.was_selected && !b.is_correct;
const inLib = b.in_library || b.auto_added;
let actionHtml = '';
if (b.auto_added) {
actionHtml = `<span class="wb-badge badge-auto-added"><i class="ph-check-circle-fill"></i> В словаре (SRS)</span>`;
} else if (inLib) {
actionHtml = `<span class="wb-badge badge-in-lib"><i class="ph-check"></i> В библиотеке</span>`;
} else {
actionHtml = `<button type="button" class="btn btn-xs btn-outline btn-add-option-word" data-term="${escapeHtml(b.term)}" data-context="${escapeHtml(b.context_sentence || '')}">+ В словарь</button>`;
}
return `
<div class="word-breakdown-card ${isTarget ? 'is-target' : ''} ${wasChosenMistake ? 'is-chosen-mistake' : ''}">
<div class="wb-card-top">
<strong class="wb-term">${escapeHtml(b.term)}</strong>
${b.pos ? `<span class="wb-pos">${escapeHtml(b.pos)}</span>` : ''}
</div>
<div class="wb-trans">${escapeHtml(b.translation || '—')}</div>
<div class="wb-action-wrap">${actionHtml}</div>
</div>
`;
}).join('')}
</div>
</div>
`;
}
function attachBreakdownListeners(container, contextSentence) {
if (!container) return;
container.querySelectorAll('.btn-add-option-word').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const term = btn.dataset.term;
if (!term) return;
btn.disabled = true;
btn.innerHTML = '...';
try {
const termContext = btn.dataset.context || contextSentence || '';
await api.cefr.addWord(currentLevel, term, termContext);
sessionStats.ingested++;
btn.outerHTML = '<span class="wb-badge badge-just-added"><i class="ph-check-circle-fill"></i> Добавлено!</span>';
showToast(`Слово «${term}» добавлено в утренний тренажёр SRS!`, 'success');
} catch (err) {
btn.disabled = false;
btn.textContent = '+ В словарь';
showToast(`Ошибка: ${err.message}`, 'error');
}
});
});
}