Files

2226 lines
98 KiB
JavaScript

// =======================================================================
// Notes Application - Comprehensive Lesson Builder & Runner Module (Beta)
// Enables custom daily workouts: Words -> Grammar -> Listening -> Reading -> AI
// =======================================================================
import { api } from './api.js';
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech, cleanPromptTranslation, renderMarkdownTable, renderMarkdownNotes } from './ui.js';
let presets = [];
let currentPreset = null;
let customSteps = [];
let grammarRecs = null;
let articlesList = [];
let userBooks = [];
// Active Session State
let session = {
active: false,
presetName: '',
steps: [],
currentStepIdx: 0,
startTime: 0,
timerInterval: null,
elapsedSeconds: 0,
stepResults: [],
errors: [],
currentStepState: null,
};
export async function initLessonModule() {
setupEventListeners();
await loadInitialData();
}
async function loadInitialData() {
try {
const [presetsRes, recsRes, articlesRes] = await Promise.all([
api.lessons.getPresets().catch(() => ({ presets: [] })),
api.lessons.getGrammarRecommendations().catch(() => null),
api.lessons.getArticles().catch(() => ({ articles: [] })),
]);
presets = presetsRes.presets || [];
grammarRecs = recsRes;
articlesList = articlesRes.articles || [];
// Select first preset by default
if (presets.length > 0 && !currentPreset) {
selectPreset(presets[0].id);
}
} catch (e) {
console.error('Error loading lesson initial data:', e);
}
}
function setupEventListeners() {
// Top-level tabs / navigation
const startBtn = document.getElementById('lesson-start-btn');
if (startBtn) {
startBtn.addEventListener('click', () => startLessonSession());
}
const savePresetBtn = document.getElementById('lesson-save-preset-btn');
if (savePresetBtn) {
savePresetBtn.addEventListener('click', () => promptSavePreset());
}
// Runner controls
const exitBtn = document.getElementById('lesson-runner-exit-btn');
if (exitBtn) {
exitBtn.addEventListener('click', () => confirmExitSession());
}
const nextStepBtn = document.getElementById('lesson-next-step-btn');
if (nextStepBtn) {
nextStepBtn.addEventListener('click', () => advanceStep());
}
// History modal
const historyBtn = document.getElementById('lesson-history-btn');
if (historyBtn) {
historyBtn.addEventListener('click', () => openHistoryModal());
}
// Runner keyboard shortcuts listener
window.addEventListener('keydown', (e) => {
if (!session.active) return;
if (typeof session.currentStepKeyHandler === 'function') {
session.currentStepKeyHandler(e);
}
});
}
// ---------------------------------------------------------------------------
// Configurator UI & Presets
// ---------------------------------------------------------------------------
export async function openLessonConfigurator(presetId = null) {
ui.switchView('lesson-view');
await loadInitialData();
if (presetId) {
selectPreset(presetId);
} else if (currentPreset) {
renderConfigurator();
}
}
export function selectPreset(presetId) {
const found = presets.find(p => p.id === presetId);
if (!found) return;
currentPreset = found;
// Deep clone steps
customSteps = JSON.parse(JSON.stringify(found.steps || []));
renderConfigurator();
}
function renderConfigurator() {
// 1. Render Preset Chips
const chipsContainer = document.getElementById('lesson-presets-bar');
if (chipsContainer) {
chipsContainer.innerHTML = presets.map(p => `
<button class="lesson-preset-chip ${currentPreset && currentPreset.id === p.id ? 'active' : ''}"
data-id="${p.id}">
<span class="preset-icon">${p.icon || '🚀'}</span>
<span class="preset-name">${escapeHtml(p.name)}</span>
${p.is_custom ? `<span class="preset-del-btn" data-del="${p.custom_id}" title="Удалить шаблон">✕</span>` : ''}
</button>
`).join('');
chipsContainer.querySelectorAll('.lesson-preset-chip').forEach(btn => {
btn.addEventListener('click', (e) => {
if (e.target.classList.contains('preset-del-btn')) {
e.stopPropagation();
deleteCustomPreset(parseInt(e.target.dataset.del, 10));
return;
}
selectPreset(btn.dataset.id);
});
});
}
// 2. Preset Description & Duration
const descEl = document.getElementById('lesson-preset-desc');
if (descEl && currentPreset) {
descEl.textContent = currentPreset.description || '';
}
const timeEl = document.getElementById('lesson-preset-duration');
if (timeEl && currentPreset) {
timeEl.textContent = `~${currentPreset.estimated_min || 15} мин`;
}
// 3. Render Configurable Step Cards
renderStepCards();
}
function renderStepCards() {
const list = document.getElementById('lesson-steps-list');
if (!list) return;
if (!customSteps || customSteps.length === 0) {
list.innerHTML = `<div class="empty-state">Нет шагов в уроке. Добавьте блоки.</div>`;
return;
}
list.innerHTML = customSteps.map((step, idx) => {
const isFirst = idx === 0;
const isLast = idx === customSteps.length - 1;
const isAi = step.type === 'ai_debrief';
return `
<div class="lesson-step-card ${step.enabled ? '' : 'step-disabled'}" data-idx="${idx}">
<div class="step-card-header">
<div class="step-card-title-wrap">
<span class="step-num">${idx + 1}</span>
<span class="step-icon">${getStepIcon(step.type)}</span>
<strong class="step-title">${escapeHtml(step.title || getStepDefaultTitle(step.type))}</strong>
</div>
<div class="step-card-actions">
${!isAi ? `
<button class="icon-btn btn-sm move-step-up" data-idx="${idx}" ${isFirst ? 'disabled' : ''} title="Поднять выше">▲</button>
<button class="icon-btn btn-sm move-step-down" data-idx="${idx}" ${isLast || customSteps[idx+1]?.type === 'ai_debrief' ? 'disabled' : ''} title="Опустить ниже">▼</button>
<label class="switch-toggle" title="Включить / выключить шаг">
<input type="checkbox" class="step-toggle-checkbox" data-idx="${idx}" ${step.enabled ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
` : '<span class="badge badge-ai">Финальный ИИ-анализ</span>'}
</div>
</div>
<div class="step-card-body ${step.enabled ? '' : 'hidden'}">
${renderStepControls(step, idx)}
</div>
</div>
`;
}).join('');
// Attach step card events
list.querySelectorAll('.move-step-up').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx, 10);
if (idx > 0) {
const tmp = customSteps[idx];
customSteps[idx] = customSteps[idx - 1];
customSteps[idx - 1] = tmp;
renderStepCards();
}
});
});
list.querySelectorAll('.move-step-down').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.idx, 10);
if (idx < customSteps.length - 1 && customSteps[idx + 1].type !== 'ai_debrief') {
const tmp = customSteps[idx];
customSteps[idx] = customSteps[idx + 1];
customSteps[idx + 1] = tmp;
renderStepCards();
}
});
});
list.querySelectorAll('.step-toggle-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const idx = parseInt(cb.dataset.idx, 10);
customSteps[idx].enabled = cb.checked;
renderStepCards();
});
});
attachStepControlsEvents(list);
}
function getStepIcon(type) {
switch (type) {
case 'vocab': return '📇';
case 'grammar_review': return '🎯';
case 'grammar_new': return '📘';
case 'listening': return '🎧';
case 'reading': return '📖';
case 'ai_debrief': return '🤖';
default: return '📌';
}
}
function getStepDefaultTitle(type) {
switch (type) {
case 'vocab': return 'Вспоминаем слова (SRS)';
case 'grammar_review': return 'Закрепление грамматики';
case 'grammar_new': return 'Новая тема Мерфи';
case 'listening': return 'Аудирование на слух';
case 'reading': return 'Чтение и словарная работа';
case 'ai_debrief': return 'ИИ-Итог и рекомендации';
default: return 'Этап урока';
}
}
function renderStepControls(step, idx) {
if (step.type === 'vocab') {
const currentMode = step.mode || 'flashcards';
return `
<div class="step-param-row">
<div class="param-group">
<label>Количество слов:</label>
<div class="segmented-control" data-param="count" data-idx="${idx}">
${[5, 10, 15, 20].map(c => `
<button type="button" class="seg-btn ${step.count === c ? 'active' : ''}" data-val="${c}">${c}</button>
`).join('')}
</div>
</div>
<div class="param-group flex-2">
<label>Режим тренировки слов:</label>
<div class="segmented-control mode-segmented-control" data-param="mode" data-idx="${idx}">
<button type="button" class="seg-btn ${currentMode === 'flashcards' ? 'active' : ''}" data-val="flashcards">📇 Карточки</button>
<button type="button" class="seg-btn ${currentMode === 'quiz' ? 'active' : ''}" data-val="quiz">🎯 4 Варианта</button>
<button type="button" class="seg-btn ${currentMode === 'typing' ? 'active' : ''}" data-val="typing">✍️ Набор</button>
<button type="button" class="seg-btn ${currentMode === 'dictation' ? 'active' : ''}" data-val="dictation">🎧 Диктант</button>
</div>
</div>
</div>
`;
}
if (step.type === 'grammar_review') {
const weakest = grammarRecs?.weakest_unit;
const prev = grammarRecs?.previous_unit;
const allUnits = grammarRecs?.all_units || [];
return `
<div class="step-param-row">
<div class="param-group flex-2">
<label>Выбор темы для повторения:</label>
<select class="input-styled step-select-param" data-param="topic_mode" data-idx="${idx}">
<option value="weakest" ${step.topic_mode === 'weakest' ? 'selected' : ''}>
🎯 Самая слабая тема ${weakest ? `(Юнит ${weakest.unit_num}: ${weakest.title})` : ''}
</option>
<option value="previous" ${step.topic_mode === 'previous' ? 'selected' : ''}>
⏳ Предыдущая тема ${prev ? `(Юнит ${prev.unit_num}: ${prev.title})` : ''}
</option>
<optgroup label="Выбрать конкретный юнит вручную:">
${allUnits.map(u => `
<option value="unit_${u.unit_num}" ${step.topic_mode === `unit_${u.unit_num}` ? 'selected' : ''}>
Юнит ${u.unit_num}: ${escapeHtml(u.title)} (${u.level})
</option>
`).join('')}
</optgroup>
</select>
</div>
<div class="param-group">
<label>Вопросов:</label>
<div class="segmented-control" data-param="count" data-idx="${idx}">
${[3, 5, 10].map(c => `
<button type="button" class="seg-btn ${step.count === c ? 'active' : ''}" data-val="${c}">${c}</button>
`).join('')}
</div>
</div>
</div>
`;
}
if (step.type === 'grammar_new') {
const next = grammarRecs?.next_unit;
const allUnits = grammarRecs?.all_units || [];
return `
<div class="step-param-row">
<div class="param-group flex-2">
<label>Тема для изучения:</label>
<select class="input-styled step-select-param" data-param="unit_mode" data-idx="${idx}">
<option value="auto_next" ${step.unit_mode === 'auto_next' ? 'selected' : ''}>
✨ Следующая по программе ${next ? `(Юнит ${next.unit_num}: ${next.title})` : ''}
</option>
<optgroup label="Выбрать тему вручную:">
${allUnits.map(u => `
<option value="unit_${u.unit_num}" ${step.unit_mode === `unit_${u.unit_num}` ? 'selected' : ''}>
Юнит ${u.unit_num}: ${escapeHtml(u.title)}
</option>
`).join('')}
</optgroup>
</select>
</div>
<div class="param-group">
<label>Упражнений:</label>
<div class="segmented-control" data-param="exercises_count" data-idx="${idx}">
${[3, 5, 8].map(c => `
<button type="button" class="seg-btn ${step.exercises_count === c ? 'active' : ''}" data-val="${c}">${c}</button>
`).join('')}
</div>
</div>
</div>
`;
}
if (step.type === 'listening') {
return `
<div class="step-param-row">
<div class="param-group">
<label>Количество ситуаций:</label>
<div class="segmented-control" data-param="count" data-idx="${idx}">
${[1, 2, 3].map(c => `
<button type="button" class="seg-btn ${step.count === c ? 'active' : ''}" data-val="${c}">${c}</button>
`).join('')}
</div>
</div>
<div class="param-group">
<label>Тематика аудио:</label>
<select class="input-styled step-select-param" data-param="topic" data-idx="${idx}">
<option value="all" ${step.topic === 'all' ? 'selected' : ''}>Все темы (микс)</option>
<option value="tech" ${step.topic === 'tech' ? 'selected' : ''}>💻 IT & Технологии</option>
<option value="science" ${step.topic === 'science' ? 'selected' : ''}>🔬 Наука & Космос</option>
<option value="travel" ${step.topic === 'travel' ? 'selected' : ''}>✈️ Путешествия & Жизнь</option>
</select>
</div>
</div>
`;
}
if (step.type === 'reading') {
return `
<div class="step-param-row">
<div class="param-group flex-2">
<label>Источник для чтения:</label>
<select class="input-styled step-select-param" data-param="source" data-idx="${idx}">
<option value="curated_article" ${step.source === 'curated_article' ? 'selected' : ''}>
📰 Познавательная микро-статья / Научный факт (из оффлайн-базы)
</option>
<option value="active_book" ${step.source === 'active_book' ? 'selected' : ''}>
📖 Книга с полки (продолжить с текущей страницы)
</option>
</select>
</div>
<div class="param-group flex-2">
<label>Выбор статьи:</label>
<select class="input-styled step-select-param" data-param="article_id" data-idx="${idx}">
<option value="random" ${step.article_id === 'random' ? 'selected' : ''}>🎲 Случайная интересная статья</option>
${articlesList.map(a => `
<option value="${a.id}" ${step.article_id === a.id ? 'selected' : ''}>
[${a.topic}] ${escapeHtml(a.title)}
</option>
`).join('')}
</select>
</div>
</div>
`;
}
if (step.type === 'ai_debrief') {
return `
<div class="ai-debrief-info-box">
<span class="ai-box-icon">🤖</span>
<div class="ai-box-text">
<strong>Педагогический анализ сессии:</strong> нейросеть сведет все результаты воедино,
сопоставит ошибки с историей прошлых уроков, объяснит разницу по формулам Мерфи
и сформирует план на завтра.
</div>
</div>
`;
}
return '';
}
function attachStepControlsEvents(container) {
container.querySelectorAll('.segmented-control').forEach(ctrl => {
ctrl.querySelectorAll('.seg-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(ctrl.dataset.idx, 10);
const param = ctrl.dataset.param;
const rawVal = btn.dataset.val;
const val = (rawVal !== '' && !isNaN(Number(rawVal))) ? Number(rawVal) : rawVal;
if (customSteps[idx]) {
customSteps[idx][param] = val;
}
ctrl.querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateEstimatedDuration();
});
});
});
container.querySelectorAll('.step-select-param').forEach(sel => {
sel.addEventListener('change', () => {
const idx = parseInt(sel.dataset.idx, 10);
const param = sel.dataset.param;
if (customSteps[idx]) {
customSteps[idx][param] = sel.value;
}
updateEstimatedDuration();
});
});
}
function updateEstimatedDuration() {
const timeEl = document.getElementById('lesson-preset-duration');
if (timeEl) {
timeEl.textContent = `~${calculateEstimatedTime(customSteps)} мин`;
}
}
function syncDomToCustomSteps() {
const list = document.getElementById('lesson-steps-list');
if (!list) return;
list.querySelectorAll('.lesson-step-card').forEach(cardEl => {
const idx = parseInt(cardEl.dataset.idx, 10);
if (isNaN(idx) || !customSteps[idx]) return;
const cb = cardEl.querySelector('.step-toggle-checkbox');
if (cb) customSteps[idx].enabled = cb.checked;
cardEl.querySelectorAll('.segmented-control').forEach(ctrl => {
const param = ctrl.dataset.param;
const activeBtn = ctrl.querySelector('.seg-btn.active');
if (param && activeBtn) {
const rawVal = activeBtn.dataset.val;
customSteps[idx][param] = (rawVal !== '' && !isNaN(Number(rawVal))) ? Number(rawVal) : rawVal;
}
});
cardEl.querySelectorAll('.step-select-param').forEach(sel => {
const param = sel.dataset.param;
if (param && sel.value !== undefined) {
customSteps[idx][param] = sel.value;
}
});
});
}
async function promptSavePreset() {
const name = prompt('Введите название вашего нового шаблона урока:', 'Мой тренировочный план');
if (!name || !name.strip?.() && !name.trim()) return;
try {
const res = await api.lessons.savePreset({
name: name.trim(),
steps: customSteps,
icon: '⚡',
description: 'Пользовательский шаблон',
estimated_min: calculateEstimatedTime(customSteps)
});
ui.showToast('✅ Шаблон урока успешно сохранён!', 'success');
await loadInitialData();
if (res.preset) {
selectPreset(res.preset.id);
}
} catch (e) {
ui.showToast(`Ошибка сохранения: ${e.message}`, 'error');
}
}
async function deleteCustomPreset(customId) {
if (!confirm('Вы действительно хотите удалить этот пользовательский шаблон?')) return;
try {
await api.lessons.deletePreset(customId);
ui.showToast('Шаблон удален', 'info');
await loadInitialData();
selectPreset(presets[0]?.id);
} catch (e) {
ui.showToast(`Ошибка удаления: ${e.message}`, 'error');
}
}
function calculateEstimatedTime(steps) {
let minutes = 0;
for (const s of steps) {
if (!s.enabled) continue;
if (s.type === 'vocab') minutes += Math.ceil((s.count || 10) * 0.4);
else if (s.type === 'grammar_review') minutes += Math.ceil((s.count || 5) * 0.6);
else if (s.type === 'grammar_new') minutes += 6;
else if (s.type === 'listening') minutes += (s.count || 1) * 2;
else if (s.type === 'reading') minutes += 4;
else if (s.type === 'ai_debrief') minutes += 1;
}
return Math.max(5, minutes);
}
// ---------------------------------------------------------------------------
// Lesson Session Runner
// ---------------------------------------------------------------------------
export async function startLessonSession(preset = null) {
if (!preset) {
syncDomToCustomSteps();
}
const stepsToRun = (preset ? preset.steps : customSteps).filter(s => s.enabled);
if (!stepsToRun || stepsToRun.length === 0) {
ui.showToast('В уроке нет активных блоков. Включите хотя бы один блок.', 'warning');
return;
}
session = {
active: true,
presetName: currentPreset?.name || 'Персональный урок',
steps: stepsToRun,
currentStepIdx: 0,
startTime: Date.now(),
timerInterval: null,
elapsedSeconds: 0,
stepResults: [],
errors: [],
currentStepState: null,
};
// Show Runner View
ui.switchView('lesson-runner-view');
// Start timer
clearInterval(session.timerInterval);
session.timerInterval = setInterval(() => {
session.elapsedSeconds++;
updateRunnerTimer();
}, 1000);
renderRunnerTopBar();
await launchStep(0);
}
function updateRunnerTimer() {
const el = document.getElementById('lesson-runner-timer');
if (!el) return;
const mins = Math.floor(session.elapsedSeconds / 60);
const secs = session.elapsedSeconds % 60;
el.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
function renderRunnerTopBar() {
const bar = document.getElementById('lesson-runner-breadcrumbs');
if (!bar) return;
bar.innerHTML = session.steps.map((st, idx) => {
let statusClass = 'step-pending';
if (idx < session.currentStepIdx) statusClass = 'step-done';
else if (idx === session.currentStepIdx) statusClass = 'step-active';
return `
<div class="runner-crumb ${statusClass}" title="${escapeHtml(st.title || st.type)}">
<span class="crumb-num">${idx < session.currentStepIdx ? '✓' : idx + 1}</span>
<span class="crumb-label">${getStepIcon(st.type)} ${escapeHtml(st.title || st.type)}</span>
</div>
`;
}).join('<div class="crumb-separator">➔</div>');
// Progress bar fill
const progressFill = document.getElementById('lesson-runner-progress-fill');
if (progressFill) {
const pct = Math.round((session.currentStepIdx / Math.max(1, session.steps.length)) * 100);
progressFill.style.width = `${pct}%`;
}
}
async function launchStep(idx) {
session.currentStepIdx = idx;
renderRunnerTopBar();
const step = session.steps[idx];
const container = document.getElementById('lesson-step-content');
if (!container) return;
container.innerHTML = `
<div class="loading-state">
<div class="spinner"></div>
<p>Загрузка этапа: ${escapeHtml(step.title || step.type)}...</p>
</div>
`;
try {
if (step.type === 'vocab') {
await runVocabStep(step, container);
} else if (step.type === 'grammar_review') {
await runGrammarReviewStep(step, container);
} else if (step.type === 'grammar_new') {
await runGrammarNewStep(step, container);
} else if (step.type === 'listening') {
await runListeningStep(step, container);
} else if (step.type === 'reading') {
await runReadingStep(step, container);
} else if (step.type === 'ai_debrief') {
await runAiDebriefStep(step, container);
}
} catch (err) {
console.error('Error running step:', err);
container.innerHTML = `
<div class="error-state">
<p>Ошибка загрузки этапа: ${escapeHtml(err.message)}</p>
<button class="btn btn-primary" onclick="window.advanceLessonStep()">Пропустить этот этап</button>
</div>
`;
}
}
export function advanceStep() {
if (session.currentStepIdx + 1 < session.steps.length) {
launchStep(session.currentStepIdx + 1);
} else {
finishSession();
}
}
window.advanceLessonStep = advanceStep;
function confirmExitSession() {
if (confirm('Прервать текущий урок? Набранный прогресс по пройденным этапам сохранится.')) {
clearInterval(session.timerInterval);
session.active = false;
ui.switchView('lesson-view');
renderConfigurator();
}
}
// ---------------------------------------------------------------------------
// Step 1: Vocab Runner (SRS)
// ---------------------------------------------------------------------------
async function runVocabStep(step, container) {
const limit = step.count || 10;
const mode = step.mode || 'flashcards';
// Fetch queue
const res = await api.srs.getQueue({ limit: limit });
let cards = res.cards || (Array.isArray(res) ? res : []);
if (cards.length === 0) {
// Fallback: fetch random cards from library
const allRes = await api.cards.getAll({ limit: limit });
cards = Array.isArray(allRes) ? allRes : (allRes.cards || []);
}
// Distractor candidate pool for quiz mode
let distractorPool = [];
if (mode === 'quiz') {
try {
const extraRes = await api.cards.getAll({ limit: 80 });
const extraCards = Array.isArray(extraRes) ? extraRes : (extraRes.cards || []);
distractorPool = extraCards.map(c => c.translation).filter(Boolean);
} catch (_) {}
}
if (cards.length === 0) {
container.innerHTML = `
<div class="step-card-box text-center">
<h3>📇 Вспоминаем слова</h3>
<p>В вашем словаре пока нет слов для повторения. Добавьте первые слова через поиск или читалку!</p>
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">Продолжить дальше ➔</button>
</div>
`;
session.stepResults.push({ type: 'vocab', title: step.title, score: 100, correct: 0, total: 0 });
return;
}
let cardIdx = 0;
let correctCount = 0;
const totalCards = cards.length;
let stepActive = true;
function renderCurrentCard() {
if (cardIdx >= totalCards) {
stepActive = false;
session.currentStepKeyHandler = null;
const score = Math.round((correctCount / totalCards) * 100);
session.stepResults.push({
type: 'vocab',
title: step.title,
score: score,
correct: correctCount,
total: totalCards,
mode: mode
});
container.innerHTML = `
<div class="step-card-box text-center step-completed-box">
<div class="celebrate-badge">🎉</div>
<h3>Блок слов завершён!</h3>
<p class="score-highlight">Результат: <strong>${correctCount} из ${totalCards}</strong> (${score}%)</p>
<div class="step-summary-badges mt-2">
<span class="badge badge-success">✓ Верно: ${correctCount}</span>
${totalCards - correctCount > 0 ? `<span class="badge badge-danger">✗ Ошибок: ${totalCards - correctCount}</span>` : ''}
</div>
<button class="btn btn-primary btn-lg mt-4" id="vocab-finish-btn">
Перейти к следующему блоку ➔
</button>
</div>
`;
document.getElementById('vocab-finish-btn')?.addEventListener('click', () => {
advanceStep();
});
return;
}
const card = cards[cardIdx];
if (mode === 'quiz') {
renderQuizCard(card);
} else if (mode === 'typing') {
renderTypingCard(card);
} else if (mode === 'dictation') {
renderDictationCard(card);
} else {
renderFlashcardCard(card);
}
}
// -----------------------------------------------------------------------
// Mode A: 4 Options Quiz
// -----------------------------------------------------------------------
function renderQuizCard(card) {
const correctText = cleanPromptTranslation(card.translation);
const candidates = [
...cards.filter(c => c.id !== card.id && c.translation).map(c => cleanPromptTranslation(c.translation)),
...distractorPool.filter(t => t && cleanPromptTranslation(t) !== correctText).map(t => cleanPromptTranslation(t))
];
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
shuffleArray(uniqueCandidates);
let options = [correctText, ...uniqueCandidates.slice(0, 3)];
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
while (options.length < 4) {
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
options.push(f);
}
options = shuffleArray(options);
let answered = false;
container.innerHTML = `
<div class="step-card-box lesson-quiz-card">
<div class="step-mini-header">
<div class="step-tags-wrap">
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
<span class="badge badge-outline">🎯 4 Варианта</span>
</div>
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
</div>
<div class="step-term-display mt-3">
<h2 class="display-term">${escapeHtml(card.term)}</h2>
${card.phonetic ? `<span class="display-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
</div>
${card.context_sentence ? `
<div class="step-context-box mt-3">
<em class="context-quote">"${escapeHtml(card.context_sentence)}"</em>
</div>
` : ''}
<div class="lesson-quiz-options-grid mt-4">
${options.map((opt, oIdx) => `
<button type="button" class="lesson-quiz-opt-btn" data-val="${escapeHtml(opt)}" data-num="${oIdx + 1}">
<span class="quiz-opt-num">${oIdx + 1}</span>
<span class="quiz-opt-text">${escapeHtml(opt)}</span>
</button>
`).join('')}
</div>
<div id="lesson-quiz-feedback" class="trainer-feedback-box hidden mt-3"></div>
<div id="lesson-quiz-next-bar" class="hidden mt-3">
<button id="lesson-quiz-next-btn" class="btn btn-primary btn-lg w-100">
Продолжить ➔ (Enter или Пробел)
</button>
</div>
</div>
`;
const playCardAudio = () => {
if (card.audio_url) playAudio(card.audio_url);
else playTextSpeech(card.term, 'en');
};
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
const optBtns = container.querySelectorAll('.lesson-quiz-opt-btn');
const handleOptionClick = (btn) => {
if (answered) return;
answered = true;
const selectedText = btn.dataset.val;
const isCorrect = selectedText === correctText;
optBtns.forEach(b => {
b.disabled = true;
if (b.dataset.val === correctText) {
b.classList.add('opt-correct');
} else if (b === btn && !isCorrect) {
b.classList.add('opt-wrong');
}
});
playCardAudio();
const fb = document.getElementById('lesson-quiz-feedback');
const nextBar = document.getElementById('lesson-quiz-next-bar');
const nextBtn = document.getElementById('lesson-quiz-next-btn');
if (isCorrect) {
correctCount++;
if (fb) {
fb.className = 'trainer-feedback-box feedback-correct';
fb.innerHTML = '🎉 <strong>Отлично! Правильный перевод.</strong>';
fb.classList.remove('hidden');
}
api.srs.submitReview(card.id, 4).catch(() => {});
} else {
session.errors.push({
type: 'vocab',
term: card.term,
translation: card.translation,
context: card.context_sentence || '',
user_answer: selectedText,
mode: 'quiz'
});
if (fb) {
fb.className = 'trainer-feedback-box feedback-wrong';
fb.innerHTML = `❌ <strong>Ошибка!</strong> Правильно: <em>${escapeHtml(correctText)}</em>`;
fb.classList.remove('hidden');
}
api.srs.submitReview(card.id, 1).catch(() => {});
}
if (nextBar) nextBar.classList.remove('hidden');
if (nextBtn) nextBtn.focus();
};
optBtns.forEach(btn => {
btn.addEventListener('click', () => handleOptionClick(btn));
});
const nextAction = () => {
cardIdx++;
renderCurrentCard();
};
document.getElementById('lesson-quiz-next-btn')?.addEventListener('click', nextAction);
session.currentStepKeyHandler = (e) => {
if (!stepActive) return;
if (!answered) {
if (['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
const n = parseInt(e.key, 10);
if (optBtns[n - 1]) handleOptionClick(optBtns[n - 1]);
} else if (e.code === 'KeyS' || e.code === 'KeyA') {
e.preventDefault();
playCardAudio();
}
} else {
if (e.key === 'Enter' || e.code === 'Space') {
e.preventDefault();
nextAction();
}
}
};
}
// -----------------------------------------------------------------------
// Mode B: Traditional Flashcards
// -----------------------------------------------------------------------
function renderFlashcardCard(card) {
let isFlipped = false;
container.innerHTML = `
<div class="step-card-box lesson-flashcard-card">
<div class="step-mini-header">
<div class="step-tags-wrap">
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
<span class="badge badge-outline">📇 Карточки</span>
</div>
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
</div>
<div class="step-term-display mt-3">
<h2 class="display-term">${escapeHtml(card.term)}</h2>
${card.phonetic ? `<span class="display-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
</div>
${card.context_sentence ? `
<div class="step-context-box mt-3">
<em class="context-quote">"${escapeHtml(card.context_sentence)}"</em>
</div>
` : ''}
<!-- Reveal / Options -->
<div id="vocab-answer-box" class="mt-4">
<button id="vocab-show-btn" class="btn btn-outline btn-lg w-100">
👁️ Показать перевод (Пробел)
</button>
</div>
<div id="vocab-rating-box" class="srs-actions-grid hidden mt-4">
<div class="translation-reveal-banner mb-3">
<div class="tr-label text-muted text-sm">Перевод:</div>
<strong class="tr-text">${escapeHtml(card.translation)}</strong>
${card.definition_en ? `<div class="tr-definition text-muted mt-1">${escapeHtml(card.definition_en)}</div>` : ''}
</div>
<div class="rating-buttons-row">
<button class="btn btn-danger srs-btn-rating" data-rating="1">
<span class="rating-key">1</span>
<span class="rating-label">Снова</span>
</button>
<button class="btn btn-warning srs-btn-rating" data-rating="2">
<span class="rating-key">2</span>
<span class="rating-label">Трудно</span>
</button>
<button class="btn btn-primary srs-btn-rating" data-rating="3">
<span class="rating-key">3</span>
<span class="rating-label">Хорошо</span>
</button>
<button class="btn btn-success srs-btn-rating" data-rating="4">
<span class="rating-key">4</span>
<span class="rating-label">Легко</span>
</button>
</div>
</div>
</div>
`;
const playCardAudio = () => {
if (card.audio_url) playAudio(card.audio_url);
else playTextSpeech(card.term, 'en');
};
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
const flipCard = () => {
if (isFlipped) return;
isFlipped = true;
document.getElementById('vocab-answer-box')?.classList.add('hidden');
document.getElementById('vocab-rating-box')?.classList.remove('hidden');
playCardAudio();
};
document.getElementById('vocab-show-btn')?.addEventListener('click', flipCard);
const handleRating = async (rating) => {
if (rating >= 3) {
correctCount++;
} else {
session.errors.push({
type: 'vocab',
term: card.term,
translation: card.translation,
context: card.context_sentence || '',
rating: rating,
mode: 'flashcards'
});
}
api.srs.submitReview(card.id, rating).catch(() => {});
cardIdx++;
renderCurrentCard();
};
container.querySelectorAll('.srs-btn-rating').forEach(btn => {
btn.addEventListener('click', () => {
const r = parseInt(btn.dataset.rating, 10);
handleRating(r);
});
});
session.currentStepKeyHandler = (e) => {
if (!stepActive) return;
if (!isFlipped) {
if (e.code === 'Space' || e.key === 'Enter') {
e.preventDefault();
flipCard();
} else if (e.code === 'KeyS' || e.code === 'KeyA') {
e.preventDefault();
playCardAudio();
}
} else {
if (['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
handleRating(parseInt(e.key, 10));
}
}
};
}
// -----------------------------------------------------------------------
// Mode C: Active Typing
// -----------------------------------------------------------------------
function renderTypingCard(card) {
let answered = false;
container.innerHTML = `
<div class="step-card-box lesson-typing-card">
<div class="step-mini-header">
<div class="step-tags-wrap">
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
<span class="badge badge-outline">✍️ Набор слова</span>
</div>
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
</div>
<div class="step-prompt-display text-center mt-3">
<span class="text-muted text-sm">Переведите на английский:</span>
<h2 class="display-term display-prompt">${escapeHtml(cleanPromptTranslation(card.translation))}</h2>
</div>
${card.context_sentence ? `
<div class="step-context-box mt-3 text-center">
<em class="context-quote">"${escapeHtml(card.context_sentence.replace(new RegExp(escapeRegex(card.term), 'gi'), '_____'))}"</em>
</div>
` : ''}
<form id="lesson-typing-form" class="mt-4">
<div class="typing-input-wrap">
<input type="text" id="lesson-typing-input" class="input-styled input-lg typing-main-input"
placeholder="Введите английское слово..." autocomplete="off" autocapitalize="off" spellcheck="false" autofocus>
<button type="submit" id="lesson-typing-submit-btn" class="btn btn-primary btn-lg">
Проверить (Enter)
</button>
</div>
</form>
<div id="lesson-typing-feedback" class="trainer-feedback-box hidden mt-3"></div>
<div id="lesson-typing-next-bar" class="hidden mt-3">
<button id="lesson-typing-next-btn" class="btn btn-primary btn-lg w-100">
Продолжить ➔ (Enter или Пробел)
</button>
</div>
</div>
`;
const inputEl = document.getElementById('lesson-typing-input');
inputEl?.focus();
const playCardAudio = () => {
if (card.audio_url) playAudio(card.audio_url);
else playTextSpeech(card.term, 'en');
};
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
const checkAnswer = () => {
if (answered) return;
answered = true;
const userVal = inputEl?.value.trim().toLowerCase() || '';
const targetVal = card.term.trim().toLowerCase();
const isMatch = userVal === targetVal;
if (inputEl) inputEl.disabled = true;
playCardAudio();
const fb = document.getElementById('lesson-typing-feedback');
const nextBar = document.getElementById('lesson-typing-next-bar');
const nextBtn = document.getElementById('lesson-typing-next-btn');
if (isMatch) {
correctCount++;
if (fb) {
fb.className = 'trainer-feedback-box feedback-correct';
fb.innerHTML = `✅ <strong>Верно!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
fb.classList.remove('hidden');
}
api.srs.submitReview(card.id, 4).catch(() => {});
} else {
session.errors.push({
type: 'vocab',
term: card.term,
translation: card.translation,
user_answer: userVal,
mode: 'typing'
});
if (fb) {
fb.className = 'trainer-feedback-box feedback-wrong';
fb.innerHTML = `❌ <strong>Ошибка:</strong> правильное написание — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
fb.classList.remove('hidden');
}
api.srs.submitReview(card.id, 1).catch(() => {});
}
if (nextBar) nextBar.classList.remove('hidden');
if (nextBtn) nextBtn.focus();
};
document.getElementById('lesson-typing-form')?.addEventListener('submit', (e) => {
e.preventDefault();
checkAnswer();
});
const nextAction = () => {
cardIdx++;
renderCurrentCard();
};
document.getElementById('lesson-typing-next-btn')?.addEventListener('click', nextAction);
session.currentStepKeyHandler = (e) => {
if (!stepActive) return;
if (answered && (e.key === 'Enter' || e.code === 'Space')) {
e.preventDefault();
nextAction();
}
};
}
// -----------------------------------------------------------------------
// Mode D: Dictation
// -----------------------------------------------------------------------
function renderDictationCard(card) {
let answered = false;
container.innerHTML = `
<div class="step-card-box lesson-dictation-card">
<div class="step-mini-header">
<div class="step-tags-wrap">
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
<span class="badge badge-outline">🎧 Диктант</span>
</div>
</div>
<div class="dictation-hero-play text-center mt-3">
<button id="dictation-big-audio-btn" class="btn btn-secondary btn-lg dictation-play-btn" type="button">
🔊 Прослушать слово (Пробел)
</button>
${card.translation ? `<p class="dictation-hint text-muted mt-2">Подсказка перевода: ${escapeHtml(cleanPromptTranslation(card.translation))}</p>` : ''}
</div>
<form id="lesson-dictation-form" class="mt-4">
<div class="typing-input-wrap">
<input type="text" id="lesson-dictation-input" class="input-styled input-lg typing-main-input"
placeholder="Запишите услышанное английское слово..." autocomplete="off" autocapitalize="off" spellcheck="false" autofocus>
<button type="submit" class="btn btn-primary btn-lg">
Проверить (Enter)
</button>
</div>
</form>
<div id="lesson-dictation-feedback" class="trainer-feedback-box hidden mt-3"></div>
<div id="lesson-dictation-next-bar" class="hidden mt-3">
<button id="lesson-dictation-next-btn" class="btn btn-primary btn-lg w-100">
Продолжить ➔ (Enter или Пробел)
</button>
</div>
</div>
`;
const playCardAudio = () => {
if (card.audio_url) playAudio(card.audio_url);
else playTextSpeech(card.term, 'en');
};
setTimeout(playCardAudio, 200);
document.getElementById('dictation-big-audio-btn')?.addEventListener('click', playCardAudio);
const inputEl = document.getElementById('lesson-dictation-input');
inputEl?.focus();
const checkAnswer = () => {
if (answered) return;
answered = true;
const userVal = inputEl?.value.trim().toLowerCase() || '';
const targetVal = card.term.trim().toLowerCase();
const isMatch = userVal === targetVal;
if (inputEl) inputEl.disabled = true;
playCardAudio();
const fb = document.getElementById('lesson-dictation-feedback');
const nextBar = document.getElementById('lesson-dictation-next-bar');
const nextBtn = document.getElementById('lesson-dictation-next-btn');
if (isMatch) {
correctCount++;
if (fb) {
fb.className = 'trainer-feedback-box feedback-correct';
fb.innerHTML = `✅ <strong>Верно на слух!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
fb.classList.remove('hidden');
}
api.srs.submitReview(card.id, 4).catch(() => {});
} else {
session.errors.push({
type: 'vocab',
term: card.term,
translation: card.translation,
user_answer: userVal,
mode: 'dictation'
});
if (fb) {
fb.className = 'trainer-feedback-box feedback-wrong';
fb.innerHTML = `❌ <strong>Ошибка:</strong> правильное слово — <strong>${escapeHtml(card.term)}</strong> (${escapeHtml(card.translation)})`;
fb.classList.remove('hidden');
}
api.srs.submitReview(card.id, 1).catch(() => {});
}
if (nextBar) nextBar.classList.remove('hidden');
if (nextBtn) nextBtn.focus();
};
document.getElementById('lesson-dictation-form')?.addEventListener('submit', (e) => {
e.preventDefault();
checkAnswer();
});
const nextAction = () => {
cardIdx++;
renderCurrentCard();
};
document.getElementById('lesson-dictation-next-btn')?.addEventListener('click', nextAction);
session.currentStepKeyHandler = (e) => {
if (!stepActive) return;
if (answered && (e.key === 'Enter' || e.code === 'Space')) {
e.preventDefault();
nextAction();
} else if (!answered && e.code === 'Space' && document.activeElement !== inputEl) {
e.preventDefault();
playCardAudio();
}
};
}
renderCurrentCard();
}
// ---------------------------------------------------------------------------
// Step 2: Grammar Review Runner
// ---------------------------------------------------------------------------
async function runGrammarReviewStep(step, container) {
let targetUnitNum = 1;
if (step.topic_mode === 'weakest') {
targetUnitNum = grammarRecs?.weakest_unit?.unit_num || 1;
} else if (step.topic_mode === 'previous') {
targetUnitNum = grammarRecs?.previous_unit?.unit_num || 1;
} else if (step.topic_mode && step.topic_mode.startsWith('unit_')) {
targetUnitNum = parseInt(step.topic_mode.replace('unit_', ''), 10) || 1;
}
const unitDetail = await api.grammar.getUnit(targetUnitNum);
const exercises = unitDetail.exercises || [];
const limit = Math.min(step.count || 5, exercises.length);
const selectedExercises = exercises.slice(0, limit);
let exIdx = 0;
let correctCount = 0;
function renderCurrentExercise() {
if (exIdx >= selectedExercises.length) {
const score = Math.round((correctCount / Math.max(1, selectedExercises.length)) * 100);
session.stepResults.push({
type: 'grammar_review',
title: step.title,
score: score,
correct: correctCount,
total: selectedExercises.length,
unit_num: targetUnitNum,
unit_title: unitDetail.title
});
container.innerHTML = `
<div class="step-card-box text-center step-completed-box">
<div class="celebrate-badge">🎯</div>
<h3>Закрепление темы завершено!</h3>
<p class="unit-subtitle">Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}</p>
<p class="score-highlight">Результат: <strong>${correctCount} из ${selectedExercises.length}</strong> (${score}%)</p>
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">
Перейти к следующему блоку ➔
</button>
</div>
`;
return;
}
const ex = selectedExercises[exIdx];
container.innerHTML = `
<div class="step-card-box">
<div class="step-mini-header">
<span class="badge">Повторение: Юнит ${unitDetail.unit_num} (${exIdx + 1} из ${selectedExercises.length})</span>
<span class="badge-formula">${escapeHtml(unitDetail.golden_formula || '')}</span>
</div>
${unitDetail.cheatsheet_table ? `
<div class="practice-cheatsheet-toggle-wrap mt-2">
<button type="button" id="review-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
<span>📋 Шпаргалка по теме</span>
<i class="ph-caret-down"></i>
</button>
<div id="review-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
${renderMarkdownTable(unitDetail.cheatsheet_table)}
</div>
</div>
` : ''}
<div class="grammar-question-box mt-3">
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
</div>
<div class="grammar-options-grid mt-4">
${(ex.options || []).map((opt, oIdx) => `
<button class="grammar-opt-btn" data-val="${escapeHtml(opt)}">
<span class="opt-key">${oIdx + 1}</span>
<span class="opt-text">${escapeHtml(opt)}</span>
</button>
`).join('')}
</div>
<div id="grammar-feedback-box" class="mt-4 hidden"></div>
</div>
`;
const reviewCsBtn = document.getElementById('review-cheatsheet-toggle-btn');
const reviewCsDrawer = document.getElementById('review-cheatsheet-drawer');
if (reviewCsBtn && reviewCsDrawer) {
reviewCsBtn.addEventListener('click', () => {
const isHidden = reviewCsDrawer.classList.contains('hidden');
reviewCsDrawer.classList.toggle('hidden', !isHidden);
reviewCsBtn.innerHTML = !isHidden
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
});
}
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
btn.addEventListener('click', async () => {
// Disable all options
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
const userChoice = btn.dataset.val;
// Submit answer to backend
let isCorrect = false;
let explanation = '';
try {
const ansRes = await api.grammar.submitAnswer(targetUnitNum, exIdx, userChoice);
isCorrect = ansRes.correct;
explanation = ansRes.explanation || ex.explanation || '';
} catch {
isCorrect = userChoice.trim().toLowerCase() === ex.target.trim().toLowerCase();
explanation = ex.explanation || '';
}
if (isCorrect) {
btn.classList.add('opt-correct');
correctCount++;
} else {
btn.classList.add('opt-wrong');
// Highlight correct button
container.querySelectorAll('.grammar-opt-btn').forEach(b => {
if (b.dataset.val === ex.target) b.classList.add('opt-correct');
});
// Record error
session.errors.push({
type: 'grammar',
unit_num: targetUnitNum,
unit_title: unitDetail.title,
formula: unitDetail.golden_formula,
user_answer: userChoice,
correct_answer: ex.target,
sentence: ex.sentence,
explanation: explanation
});
}
const fb = document.getElementById('grammar-feedback-box');
if (fb) {
fb.classList.remove('hidden');
fb.innerHTML = `
<div class="feedback-banner ${isCorrect ? 'fb-success' : 'fb-danger'}">
<div class="fb-title">${isCorrect ? '✅ Абсолютно верно!' : '❌ Не совсем точно'}</div>
<div class="fb-text">${escapeHtml(explanation)}</div>
<button id="next-grammar-btn" class="btn btn-primary mt-3">
${exIdx + 1 < selectedExercises.length ? 'Следующий вопрос ➔' : 'Завершить блок грамматики ➔'}
</button>
</div>
`;
document.getElementById('next-grammar-btn')?.addEventListener('click', () => {
exIdx++;
renderCurrentExercise();
});
}
});
});
}
renderCurrentExercise();
}
// ---------------------------------------------------------------------------
// Step 3: Grammar New Topic Runner
// ---------------------------------------------------------------------------
async function runGrammarNewStep(step, container) {
let targetUnitNum = 2;
if (step.unit_mode === 'auto_next') {
targetUnitNum = grammarRecs?.next_unit?.unit_num || 2;
} else if (step.unit_mode && step.unit_mode.startsWith('unit_')) {
targetUnitNum = parseInt(step.unit_mode.replace('unit_', ''), 10) || 2;
}
const unitDetail = await api.grammar.getUnit(targetUnitNum);
let stage = 'theory'; // theory -> exercises
function renderTheory() {
const contrasts = unitDetail.contrasts || unitDetail.contrast_explanation || [];
const hasCheatsheet = Boolean(unitDetail.cheatsheet_table && unitDetail.cheatsheet_table.trim());
const hasNotes = Boolean(unitDetail.detailed_notes && unitDetail.detailed_notes.trim());
const hasPrompt = Boolean(unitDetail.journaling_prompt && unitDetail.journaling_prompt.trim());
container.innerHTML = `
<div class="step-card-box">
<div class="step-mini-header">
<span class="badge">Новая тема: Юнит ${unitDetail.unit_num}</span>
<span class="badge badge-primary">${escapeHtml(unitDetail.category)}</span>
</div>
<h2 class="unit-title mt-2">${escapeHtml(unitDetail.title)}</h2>
<p class="unit-subtitle">${escapeHtml(unitDetail.subtitle || '')}</p>
<!-- Golden Formula Card -->
<div class="golden-formula-box mt-3">
<div class="formula-label">⚡ Золотая формула для тетради:</div>
<pre class="formula-code">${escapeHtml(unitDetail.golden_formula)}</pre>
</div>
<!-- Cheatsheet Markdown Table -->
${hasCheatsheet ? `
<div class="lesson-grammar-section mt-4">
<div class="section-title"><span class="title-icon">📊</span> Наглядная шпаргалка и правила (Таблица):</div>
${renderMarkdownTable(unitDetail.cheatsheet_table)}
</div>
` : ''}
<!-- Detailed Notes -->
${hasNotes ? `
<div class="lesson-grammar-section mt-4">
<div class="section-title"><span class="title-icon">📖</span> Подробный конспект темы (Raymond Murphy):</div>
${renderMarkdownNotes(unitDetail.detailed_notes)}
</div>
` : ''}
<!-- Contrast Situations -->
${contrasts.length > 0 ? `
<div class="lesson-grammar-section mt-4">
<div class="section-title"><span class="title-icon">⚖️</span> Метод контрастов (В чём суть и когда применять):</div>
${contrasts.map(c => `
<div class="contrast-card mt-2">
<div class="contrast-row">
<div class="contrast-col a">
<div class="col-title">${escapeHtml(c.situation_a)}</div>
<div class="col-example">${escapeHtml(c.example_a)}</div>
<div class="col-trans">${escapeHtml(c.trans_a)}</div>
</div>
<div class="contrast-col b">
<div class="col-title">${escapeHtml(c.situation_b)}</div>
<div class="col-example">${escapeHtml(c.example_b)}</div>
<div class="col-trans">${escapeHtml(c.trans_b)}</div>
</div>
</div>
<div class="contrast-diff mt-2">💡 <strong>Ключевая разница:</strong> ${escapeHtml(c.key_difference)}</div>
</div>
`).join('')}
</div>
` : ''}
<!-- Handwritten Journaling Prompt -->
${hasPrompt ? `
<div class="grammar-journaling-card mt-4">
<div class="journaling-prompt-box">
<div class="formula-label">📓 Персональный дневник от руки (Motor Memory):</div>
<p class="journaling-prompt-text mt-1">${escapeHtml(unitDetail.journaling_prompt)}</p>
</div>
</div>
` : ''}
<!-- Handwritten Journaling Checkbox -->
<div class="journaling-box mt-4">
<label class="checkbox-container">
<input type="checkbox" id="hw-checkbox" ${unitDetail.handwritten_logged ? 'checked' : ''}>
<span class="checkmark"></span>
<span class="hw-text">✍️ Я записал формулу и пример в бумажную тетрадь</span>
</label>
</div>
<div class="mt-4 text-center">
<button id="start-new-exercises-btn" class="btn btn-primary btn-lg">
Перейти к закреплению на упражнениях ➔
</button>
</div>
</div>
`;
document.getElementById('start-new-exercises-btn')?.addEventListener('click', () => {
const hwChecked = document.getElementById('hw-checkbox')?.checked || false;
try {
api.grammar.updateHandwritten(unitDetail.id, hwChecked);
} catch {}
stage = 'exercises';
renderExercises();
});
}
function renderExercises() {
const exercises = unitDetail.exercises || [];
const limit = Math.min(step.exercises_count || 5, exercises.length);
const selectedExercises = exercises.slice(0, limit);
let exIdx = 0;
let correctCount = 0;
function renderEx() {
if (exIdx >= selectedExercises.length) {
const score = Math.round((correctCount / Math.max(1, selectedExercises.length)) * 100);
session.stepResults.push({
type: 'grammar_new',
title: step.title,
score: score,
correct: correctCount,
total: selectedExercises.length,
unit_num: targetUnitNum,
unit_title: unitDetail.title
});
// Record unit completion
try {
api.grammar.recordCompletion(unitDetail.id, score, true);
} catch {}
container.innerHTML = `
<div class="step-card-box text-center step-completed-box">
<div class="celebrate-badge">📘</div>
<h3>Тема успешно освоена!</h3>
<p class="unit-subtitle">Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}</p>
<p class="score-highlight">Результат практики: <strong>${correctCount} из ${selectedExercises.length}</strong> (${score}%)</p>
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">
Перейти к следующему блоку ➔
</button>
</div>
`;
return;
}
const ex = selectedExercises[exIdx];
container.innerHTML = `
<div class="step-card-box">
<div class="step-mini-header">
<span class="badge">Практика темы (${exIdx + 1} из ${selectedExercises.length})</span>
</div>
${unitDetail.cheatsheet_table ? `
<div class="practice-cheatsheet-toggle-wrap mt-2">
<button type="button" id="lesson-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
<span>📋 Шпаргалка по теме</span>
<i class="ph-caret-down"></i>
</button>
<div id="lesson-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
${renderMarkdownTable(unitDetail.cheatsheet_table)}
</div>
</div>
` : ''}
<div class="grammar-question-box mt-3">
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
</div>
<div class="grammar-options-grid mt-4">
${(ex.options || []).map((opt, oIdx) => `
<button class="grammar-opt-btn" data-val="${escapeHtml(opt)}">
<span class="opt-key">${oIdx + 1}</span>
<span class="opt-text">${escapeHtml(opt)}</span>
</button>
`).join('')}
</div>
<div id="grammar-feedback-box" class="mt-4 hidden"></div>
</div>
`;
const lessonCsBtn = document.getElementById('lesson-cheatsheet-toggle-btn');
const lessonCsDrawer = document.getElementById('lesson-cheatsheet-drawer');
if (lessonCsBtn && lessonCsDrawer) {
lessonCsBtn.addEventListener('click', () => {
const isHidden = lessonCsDrawer.classList.contains('hidden');
lessonCsDrawer.classList.toggle('hidden', !isHidden);
lessonCsBtn.innerHTML = !isHidden
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
});
}
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
btn.addEventListener('click', async () => {
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
const userChoice = btn.dataset.val;
let isCorrect = false;
let explanation = '';
try {
const ansRes = await api.grammar.submitAnswer(targetUnitNum, exIdx, userChoice);
isCorrect = ansRes.correct;
explanation = ansRes.explanation || ex.explanation || '';
} catch {
isCorrect = userChoice.trim().toLowerCase() === ex.target.trim().toLowerCase();
explanation = ex.explanation || '';
}
if (isCorrect) {
btn.classList.add('opt-correct');
correctCount++;
} else {
btn.classList.add('opt-wrong');
container.querySelectorAll('.grammar-opt-btn').forEach(b => {
if (b.dataset.val === ex.target) b.classList.add('opt-correct');
});
session.errors.push({
type: 'grammar',
unit_num: targetUnitNum,
unit_title: unitDetail.title,
formula: unitDetail.golden_formula,
user_answer: userChoice,
correct_answer: ex.target,
sentence: ex.sentence,
explanation: explanation
});
}
const fb = document.getElementById('grammar-feedback-box');
if (fb) {
fb.classList.remove('hidden');
fb.innerHTML = `
<div class="feedback-banner ${isCorrect ? 'fb-success' : 'fb-danger'}">
<div class="fb-title">${isCorrect ? '✅ Верно!' : '❌ Ошибка в форме'}</div>
<div class="fb-text">${escapeHtml(explanation)}</div>
<button id="next-ex-btn" class="btn btn-primary mt-3">
${exIdx + 1 < selectedExercises.length ? 'Следующее задание ➔' : 'Завершить практику темы ➔'}
</button>
</div>
`;
document.getElementById('next-ex-btn')?.addEventListener('click', () => {
exIdx++;
renderEx();
});
}
});
});
}
renderEx();
}
renderTheory();
}
// ---------------------------------------------------------------------------
// Step 4: Listening Runner
// ---------------------------------------------------------------------------
async function runListeningStep(step, container) {
const totalScenarios = step.count || 1;
let scenarioIdx = 0;
let correctCount = 0;
async function renderScenario() {
if (scenarioIdx >= totalScenarios) {
const score = Math.round((correctCount / Math.max(1, totalScenarios)) * 100);
session.stepResults.push({
type: 'listening',
title: step.title,
score: score,
correct: correctCount,
total: totalScenarios
});
container.innerHTML = `
<div class="step-card-box text-center step-completed-box">
<div class="celebrate-badge">🎧</div>
<h3>Аудирование завершено!</h3>
<p class="score-highlight">Результат: <strong>${correctCount} из ${totalScenarios}</strong> (${score}%)</p>
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">
Перейти к следующему блоку ➔
</button>
</div>
`;
return;
}
const data = await api.listening.getExercise(step.topic || 'all');
const scenario = (data && (data.scenario || data)) || {};
container.innerHTML = `
<div class="step-card-box">
<div class="step-mini-header">
<span class="badge">Ситуация ${scenarioIdx + 1} из ${totalScenarios}</span>
<span class="badge badge-primary">${escapeHtml(scenario.topic || 'General')} (${escapeHtml(scenario.level || 'B1')})</span>
</div>
<div class="audio-player-card mt-3">
<div class="audio-controls-row">
<button id="play-story-btn" class="btn btn-primary btn-lg">
▶ Послушать ситуацию
</button>
<button id="audio-speed-btn" class="btn btn-outline">
Скорость: 1.0x
</button>
</div>
</div>
<div class="listening-question-section mt-4">
<div class="question-header-row">
<h4>Вопрос диктора:</h4>
<button id="play-question-btn" class="icon-btn" title="Озвучить вопрос">🔊</button>
</div>
<p class="question-en">"${escapeHtml(scenario.question)}"</p>
<p class="question-ru text-muted">(${escapeHtml(scenario.question_ru || '')})</p>
<div class="answer-input-group mt-3">
<input type="text" id="listening-answer-input" class="input-styled"
placeholder="Введите услышанный ответ на английском...">
<button id="submit-listening-btn" class="btn btn-success">Проверить</button>
</div>
</div>
<div id="listening-feedback-box" class="mt-4 hidden"></div>
</div>
`;
let storyAudio = scenario.story_audio_url ? new Audio(scenario.story_audio_url) : null;
let questionAudio = scenario.question_audio_url ? new Audio(scenario.question_audio_url) : null;
let currentPlaybackRate = 1.0;
document.getElementById('play-story-btn')?.addEventListener('click', () => {
if (storyAudio) {
storyAudio.playbackRate = currentPlaybackRate;
storyAudio.play().catch(() => {});
}
});
document.getElementById('play-question-btn')?.addEventListener('click', () => {
if (questionAudio) {
questionAudio.playbackRate = currentPlaybackRate;
questionAudio.play().catch(() => {});
}
});
document.getElementById('audio-speed-btn')?.addEventListener('click', (e) => {
currentPlaybackRate = currentPlaybackRate === 1.0 ? 0.8 : 1.0;
e.target.textContent = `Скорость: ${currentPlaybackRate}x`;
if (storyAudio) storyAudio.playbackRate = currentPlaybackRate;
if (questionAudio) questionAudio.playbackRate = currentPlaybackRate;
});
document.getElementById('submit-listening-btn')?.addEventListener('click', async () => {
const input = document.getElementById('listening-answer-input');
const userAns = input?.value.trim() || '';
const res = await api.listening.checkAnswer({
scenario_id: scenario.id,
user_answer: userAns
});
if (res.is_correct) {
correctCount++;
} else {
session.errors.push({
type: 'listening',
question: scenario.question,
user_answer: userAns,
ideal_answer: scenario.ideal_answer,
topic: scenario.topic
});
}
const fb = document.getElementById('listening-feedback-box');
if (fb) {
fb.classList.remove('hidden');
fb.innerHTML = `
<div class="feedback-banner ${res.is_correct ? 'fb-success' : 'fb-danger'}">
<div class="fb-title">${escapeHtml(res.feedback)}</div>
<div class="ideal-answer-box mt-2">
<strong>Правильный ответ:</strong> ${escapeHtml(scenario.ideal_answer)}
</div>
<div class="transcript-box mt-3">
<details>
<summary>Показать полный текст истории (транскрипт)</summary>
<p class="mt-2"><strong>EN:</strong> ${escapeHtml(scenario.story)}</p>
<p class="text-muted"><strong>RU:</strong> ${escapeHtml(scenario.story_ru)}</p>
</details>
</div>
<button id="next-listening-btn" class="btn btn-primary mt-3">
${scenarioIdx + 1 < totalScenarios ? 'Следующая ситуация ➔' : 'Завершить аудирование ➔'}
</button>
</div>
`;
document.getElementById('next-listening-btn')?.addEventListener('click', () => {
scenarioIdx++;
renderScenario();
});
}
});
}
await renderScenario();
}
// ---------------------------------------------------------------------------
// Step 5: Reading & Vocabulary Miner Runner
// ---------------------------------------------------------------------------
async function runReadingStep(step, container) {
let article = null;
if (step.article_id && step.article_id !== 'random') {
article = articlesList.find(a => a.id === step.article_id);
}
if (!article && articlesList.length > 0) {
article = articlesList[Math.floor(Math.random() * articlesList.length)];
}
if (!article) {
article = {
title: "Exploring New Horizons",
title_ru: "Исследуя новые горизонты",
topic: "Science",
paragraphs: [
"Scientific exploration expands the boundaries of human knowledge through curious observation and rigorous testing.",
"Every breakthrough in technology begins with an inquisitive mind willing to question established assumptions."
],
paragraphs_ru: [
"Научные исследования расширяют границы человеческих знаний благодаря любознательному наблюдению и строгой проверке.",
"Каждый прорыв в технологиях начинается с пытливого ума, готового подвергнуть сомнению устоявшиеся предположения."
],
vocabulary_highlights: [
{"term": "boundaries", "trans": "границы"},
{"term": "rigorous", "trans": "строгий, тщательный"},
{"term": "inquisitive", "trans": "пытливый, любознательный"}
]
};
}
let wordsSavedCount = 0;
container.innerHTML = `
<div class="step-card-box">
<div class="step-mini-header">
<span class="badge">Чтение и пополнение словаря</span>
<span class="badge badge-primary">${escapeHtml(article.topic || 'Article')}</span>
</div>
<h2 class="article-title mt-2">${escapeHtml(article.title)}</h2>
<p class="article-title-ru text-muted">${escapeHtml(article.title_ru || '')}</p>
<div class="reader-content-box mt-3" id="lesson-reader-text">
${article.paragraphs.map((p, pIdx) => `
<div class="article-paragraph-card">
<p class="article-para-en" data-pidx="${pIdx}">
${p.split(' ').map(w => `<span class="clickable-reader-word" data-word="${escapeHtml(w.replace(/[^\w'-]/g, ''))}">${escapeHtml(w)}</span>`).join(' ')}
</p>
<div class="article-para-ru-wrap">
<button class="btn btn-xs btn-ghost toggle-para-trans-btn" data-pidx="${pIdx}">
👁️ Показать перевод абзаца
</button>
<p class="article-para-ru hidden" id="para-ru-${pIdx}">
${escapeHtml(article.paragraphs_ru?.[pIdx] || '')}
</p>
</div>
</div>
`).join('')}
</div>
<!-- Vocabulary Highlights Bar -->
${article.vocabulary_highlights?.length ? `
<div class="vocab-highlights-bar mt-4">
<span class="highlights-title">💡 Ключевые выражения из статьи:</span>
<div class="highlights-chips">
${article.vocabulary_highlights.map(vh => `
<span class="vh-chip" data-term="${escapeHtml(vh.term)}" data-trans="${escapeHtml(vh.trans)}">
<strong>${escapeHtml(vh.term)}</strong> — ${escapeHtml(vh.trans)}
</span>
`).join('')}
</div>
</div>
` : ''}
<!-- Reading Word Lookup Popup -->
<div id="lesson-word-popup" class="lesson-popup hidden">
<div class="popup-header">
<strong id="popup-term">Word</strong>
<button class="popup-close-btn" id="popup-close-btn">✕</button>
</div>
<div class="popup-body">
<div id="popup-trans" class="popup-trans">Перевод...</div>
<div id="popup-phonetic" class="popup-phonetic text-muted"></div>
</div>
<div class="popup-actions mt-2">
<button id="popup-save-btn" class="btn btn-sm btn-success w-100">+ Добавить в мой словарь</button>
</div>
</div>
<div class="mt-4 text-center">
<button id="finish-reading-step-btn" class="btn btn-primary btn-lg">
Завершить чтение и перейти к ИИ-Итогу ➔
</button>
</div>
</div>
`;
// Toggle paragraph translations
container.querySelectorAll('.toggle-para-trans-btn').forEach(btn => {
btn.addEventListener('click', () => {
const pIdx = btn.dataset.pidx;
const paraRu = document.getElementById(`para-ru-${pIdx}`);
if (paraRu) {
paraRu.classList.toggle('hidden');
btn.textContent = paraRu.classList.contains('hidden') ? '👁️ Показать перевод абзаца' : '🙈 Скрыть перевод';
}
});
});
// Word popup interaction
const popup = document.getElementById('lesson-word-popup');
let selectedWordData = null;
container.querySelectorAll('.clickable-reader-word').forEach(span => {
span.addEventListener('click', async (e) => {
const term = span.dataset.word;
if (!term || term.length < 2) return;
const rect = span.getBoundingClientRect();
popup.style.top = `${rect.bottom + window.scrollY + 6}px`;
popup.style.left = `${Math.min(window.innerWidth - 260, Math.max(10, rect.left))}px`;
popup.classList.remove('hidden');
document.getElementById('popup-term').textContent = term;
document.getElementById('popup-trans').textContent = 'Поиск в словаре...';
try {
const lookupRes = await api.lookup.search(term);
selectedWordData = {
term: term,
translation: lookupRes.translation || 'Перевод',
phonetic: lookupRes.phonetic || '',
pos: lookupRes.pos || '',
context_sentence: span.closest('.article-para-en')?.textContent.trim() || ''
};
document.getElementById('popup-trans').textContent = selectedWordData.translation;
document.getElementById('popup-phonetic').textContent = selectedWordData.phonetic ? `/${selectedWordData.phonetic}/` : '';
} catch {
document.getElementById('popup-trans').textContent = 'Перевод не найден';
}
});
});
document.getElementById('popup-close-btn')?.addEventListener('click', () => {
popup?.classList.add('hidden');
});
document.getElementById('popup-save-btn')?.addEventListener('click', async () => {
if (!selectedWordData) return;
try {
await api.cards.create({
term: selectedWordData.term,
translation: selectedWordData.translation,
phonetic: selectedWordData.phonetic,
part_of_speech: selectedWordData.pos,
context_sentence: selectedWordData.context_sentence,
collection: 'General',
tags: 'reading'
});
wordsSavedCount++;
ui.showToast(`✅ Слово «${selectedWordData.term}» сохранено в словарь!`, 'success');
popup.classList.add('hidden');
} catch (e) {
ui.showToast(`Ошибка сохранения: ${e.message}`, 'error');
}
});
document.getElementById('finish-reading-step-btn')?.addEventListener('click', () => {
session.stepResults.push({
type: 'reading',
title: step.title,
score: 100,
words_saved: wordsSavedCount,
article_title: article.title
});
advanceStep();
});
}
// ---------------------------------------------------------------------------
// Step 6: AI Debrief Runner
// ---------------------------------------------------------------------------
async function runAiDebriefStep(step, container) {
clearInterval(session.timerInterval);
container.innerHTML = `
<div class="step-card-box text-center">
<div class="ai-loader-box">
<div class="spinner spinner-lg"></div>
<h3 class="mt-3">🤖 Локальный ИИ Qwen 2.5 формирует ваш персональный разбор...</h3>
<p class="text-muted">Анализируем правильные ответы, паттерны ошибок и подбираем рекомендации по грамматике.</p>
</div>
</div>
`;
// Calculate total score
let totalScore = 100;
if (session.stepResults.length > 0) {
const sum = session.stepResults.reduce((acc, s) => acc + (s.score || 0), 0);
totalScore = Math.round(sum / session.stepResults.length);
}
const payload = {
preset_name: session.presetName,
duration_seconds: session.elapsedSeconds,
total_score: totalScore,
steps: session.stepResults,
errors: session.errors
};
let report = null;
try {
const res = await api.lessons.submitDebrief(payload);
report = res.ai_report;
} catch (e) {
console.error('Debrief error:', e);
report = {
grade: totalScore >= 75 ? 'A' : 'B',
score: totalScore,
headline: 'Урок успешно завершён!',
strengths: ['Вы успешно прошли все запланированные блоки.'],
error_patterns: ['Ошибки зафиксированы и добавлены в повторение.'],
tomorrow_focus: ['Продолжайте ежедневную практику.']
};
}
renderFinalDebriefCard(container, report, totalScore);
}
function renderFinalDebriefCard(container, report, totalScore) {
const mins = Math.max(1, Math.round(session.elapsedSeconds / 60));
container.innerHTML = `
<div class="step-card-box lesson-debrief-card">
<!-- Header Banner -->
<div class="debrief-banner-header">
<div class="grade-badge-wrap">
<span class="grade-badge grade-${report.grade?.replace('+', '-plus') || 'a'}">${escapeHtml(report.grade || 'A')}</span>
<span class="score-pct">${totalScore}%</span>
</div>
<div class="debrief-titles">
<h2 class="debrief-headline">${escapeHtml(report.headline || 'Отличная работа!')}</h2>
<div class="debrief-meta text-muted">
⏱️ Время сессии: <strong>${mins} мин</strong> | 🎯 Блоков: <strong>${session.stepResults.length}</strong> | ❌ Ошибок: <strong>${session.errors.length}</strong>
</div>
</div>
</div>
<!-- Block by Block Progress -->
<div class="debrief-blocks-breakdown mt-4">
<h4>📊 Результаты по блокам урока:</h4>
<div class="blocks-scores-grid mt-2">
${session.stepResults.map(s => `
<div class="block-score-item">
<div class="bs-header">
<span class="bs-title">${getStepIcon(s.type)} ${escapeHtml(s.title || s.type)}</span>
<span class="bs-val">${s.score}%</span>
</div>
<div class="bs-bar">
<div class="bs-bar-fill" style="width: ${s.score}%"></div>
</div>
</div>
`).join('')}
</div>
</div>
<!-- Strengths -->
${report.strengths?.length ? `
<div class="debrief-section mt-4">
<h4>🌟 Что получилось лучше всего:</h4>
<ul class="debrief-list">
${report.strengths.map(st => `<li>${escapeHtml(st)}</li>`).join('')}
</ul>
</div>
` : ''}
<!-- Error Patterns & Pedagogic Explanations -->
${report.error_patterns?.length ? `
<div class="debrief-section error-patterns-section mt-4">
<h4>🔍 Педагогический анализ ошибок:</h4>
<div class="error-cards-list mt-2">
${report.error_patterns.map(ep => `
<div class="error-analysis-item">
<span class="err-icon">💡</span>
<div class="err-text">${escapeHtml(ep)}</div>
</div>
`).join('')}
</div>
</div>
` : ''}
<!-- Focus for Tomorrow -->
${report.tomorrow_focus?.length ? `
<div class="debrief-section tomorrow-focus-section mt-4">
<h4>🎯 Персональный фокус на завтра:</h4>
<ul class="tomorrow-focus-list">
${report.tomorrow_focus.map(tf => `<li>👉 ${escapeHtml(tf)}</li>`).join('')}
</ul>
</div>
` : ''}
<!-- Action Buttons -->
<div class="debrief-actions-row mt-5 text-center">
<button id="debrief-finish-btn" class="btn btn-primary btn-lg">
🏁 Завершить урок и вернуться в меню
</button>
</div>
</div>
`;
document.getElementById('debrief-finish-btn')?.addEventListener('click', () => {
session.active = false;
ui.switchView('lesson-view');
renderConfigurator();
});
}
function finishSession() {
session.active = false;
clearInterval(session.timerInterval);
ui.switchView('lesson-view');
renderConfigurator();
}
async function openHistoryModal() {
try {
const data = await api.lessons.getHistory(15);
const modal = document.getElementById('lesson-history-modal');
if (!modal) return;
const list = document.getElementById('lesson-history-list');
if (list) {
if (!data.history || data.history.length === 0) {
list.innerHTML = `<div class="empty-state">История комплексных уроков пока пуста. Пройдите ваш первый урок!</div>`;
} else {
list.innerHTML = data.history.map(h => `
<div class="lesson-history-item">
<div class="lh-header">
<span class="lh-preset">🚀 ${escapeHtml(h.preset_name)}</span>
<span class="lh-date">${h.completed_at}</span>
</div>
<div class="lh-stats">
<span class="lh-badge grade-${h.grade?.toLowerCase() || 'a'}">${h.grade} (${h.total_score}%)</span>
<span class="lh-time">⏱️ ${Math.round((h.duration_seconds || 0) / 60)} мин</span>
<span class="lh-errors">❌ ${h.errors_count} ошибок</span>
</div>
${h.headline ? `<div class="lh-headline">"${escapeHtml(h.headline)}"</div>` : ''}
</div>
`).join('');
}
}
modal.classList.remove('hidden');
document.getElementById('lesson-history-close-btn')?.addEventListener('click', () => {
modal.classList.add('hidden');
}, { once: true });
} catch (e) {
ui.showToast(`Ошибка загрузки истории: ${e.message}`, 'error');
}
}