// =======================================================================
// 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 } 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 => `
${p.icon || '🚀'}
${escapeHtml(p.name)}
${p.is_custom ? `✕ ` : ''}
`).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 = `
Нет шагов в уроке. Добавьте блоки.
`;
return;
}
list.innerHTML = customSteps.map((step, idx) => {
const isFirst = idx === 0;
const isLast = idx === customSteps.length - 1;
const isAi = step.type === 'ai_debrief';
return `
${renderStepControls(step, idx)}
`;
}).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 `
Количество слов:
${[5, 10, 15, 20].map(c => `
${c}
`).join('')}
Режим тренировки слов:
📇 Карточки
🎯 4 Варианта
✍️ Набор
🎧 Диктант
`;
}
if (step.type === 'grammar_review') {
const weakest = grammarRecs?.weakest_unit;
const prev = grammarRecs?.previous_unit;
const allUnits = grammarRecs?.all_units || [];
return `
Выбор темы для повторения:
🎯 Самая слабая тема ${weakest ? `(Юнит ${weakest.unit_num}: ${weakest.title})` : ''}
⏳ Предыдущая тема ${prev ? `(Юнит ${prev.unit_num}: ${prev.title})` : ''}
${allUnits.map(u => `
Юнит ${u.unit_num}: ${escapeHtml(u.title)} (${u.level})
`).join('')}
Вопросов:
${[3, 5, 10].map(c => `
${c}
`).join('')}
`;
}
if (step.type === 'grammar_new') {
const next = grammarRecs?.next_unit;
const allUnits = grammarRecs?.all_units || [];
return `
Тема для изучения:
✨ Следующая по программе ${next ? `(Юнит ${next.unit_num}: ${next.title})` : ''}
${allUnits.map(u => `
Юнит ${u.unit_num}: ${escapeHtml(u.title)}
`).join('')}
Упражнений:
${[3, 5, 8].map(c => `
${c}
`).join('')}
`;
}
if (step.type === 'listening') {
return `
Количество ситуаций:
${[1, 2, 3].map(c => `
${c}
`).join('')}
Тематика аудио:
Все темы (микс)
💻 IT & Технологии
🔬 Наука & Космос
✈️ Путешествия & Жизнь
`;
}
if (step.type === 'reading') {
return `
Источник для чтения:
📰 Познавательная микро-статья / Научный факт (из оффлайн-базы)
📖 Книга с полки (продолжить с текущей страницы)
Выбор статьи:
🎲 Случайная интересная статья
${articlesList.map(a => `
[${a.topic}] ${escapeHtml(a.title)}
`).join('')}
`;
}
if (step.type === 'ai_debrief') {
return `
🤖
Педагогический анализ сессии: нейросеть сведет все результаты воедино,
сопоставит ошибки с историей прошлых уроков, объяснит разницу по формулам Мерфи
и сформирует план на завтра.
`;
}
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 `
${idx < session.currentStepIdx ? '✓' : idx + 1}
${getStepIcon(st.type)} ${escapeHtml(st.title || st.type)}
`;
}).join('➔
');
// 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 = `
Загрузка этапа: ${escapeHtml(step.title || step.type)}...
`;
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 = `
Ошибка загрузки этапа: ${escapeHtml(err.message)}
Пропустить этот этап
`;
}
}
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 = `
📇 Вспоминаем слова
В вашем словаре пока нет слов для повторения. Добавьте первые слова через поиск или читалку!
Продолжить дальше ➔
`;
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 = `
🎉
Блок слов завершён!
Результат: ${correctCount} из ${totalCards} (${score}%)
✓ Верно: ${correctCount}
${totalCards - correctCount > 0 ? `✗ Ошибок: ${totalCards - correctCount} ` : ''}
Перейти к следующему блоку ➔
`;
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 = card.translation;
const candidates = [
...cards.filter(c => c.id !== card.id && c.translation).map(c => c.translation),
...distractorPool.filter(t => t && t !== correctText)
];
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
shuffleArray(uniqueCandidates);
const 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);
}
shuffleArray(options);
let answered = false;
container.innerHTML = `
${escapeHtml(card.term)}
${card.phonetic ? `${escapeHtml(card.phonetic)} ` : ''}
${card.context_sentence ? `
"${escapeHtml(card.context_sentence)}"
` : ''}
${options.map((opt, oIdx) => `
${oIdx + 1}
${escapeHtml(opt)}
`).join('')}
Продолжить ➔ (Enter или Пробел)
`;
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 = '🎉 Отлично! Правильный перевод. ';
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 = `❌ Ошибка! Правильно: ${escapeHtml(correctText)} `;
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 = `
${escapeHtml(card.term)}
${card.phonetic ? `${escapeHtml(card.phonetic)} ` : ''}
${card.context_sentence ? `
"${escapeHtml(card.context_sentence)}"
` : ''}
👁️ Показать перевод (Пробел)
Перевод:
${escapeHtml(card.translation)}
${card.definition_en ? `
${escapeHtml(card.definition_en)}
` : ''}
1
Снова
2
Трудно
3
Хорошо
4
Легко
`;
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 = `
Переведите на английский:
${escapeHtml(card.translation)}
${card.context_sentence ? `
"${escapeHtml(card.context_sentence.replace(new RegExp(escapeRegex(card.term), 'gi'), '_____'))}"
` : ''}
Продолжить ➔ (Enter или Пробел)
`;
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 = `✅ Верно! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`;
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 = `❌ Ошибка: правильное написание — ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`;
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 = `
🔊 Прослушать слово (Пробел)
${card.translation ? `
Подсказка перевода: ${escapeHtml(card.translation)}
` : ''}
Продолжить ➔ (Enter или Пробел)
`;
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 = `✅ Верно на слух! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`;
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 = `❌ Ошибка: правильное слово — ${escapeHtml(card.term)} (${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 = `
🎯
Закрепление темы завершено!
Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}
Результат: ${correctCount} из ${selectedExercises.length} (${score}%)
Перейти к следующему блоку ➔
`;
return;
}
const ex = selectedExercises[exIdx];
container.innerHTML = `
${escapeHtml(ex.sentence)}
${ex.clue ? `
💡 Подсказка: ${escapeHtml(ex.clue)}
` : ''}
${(ex.options || []).map((opt, oIdx) => `
${oIdx + 1}
${escapeHtml(opt)}
`).join('')}
`;
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 = `
${isCorrect ? '✅ Абсолютно верно!' : '❌ Не совсем точно'}
${escapeHtml(explanation)}
${exIdx + 1 < selectedExercises.length ? 'Следующий вопрос ➔' : 'Завершить блок грамматики ➔'}
`;
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.contrast_explanation || [];
container.innerHTML = `
${escapeHtml(unitDetail.title)}
${escapeHtml(unitDetail.subtitle || '')}
${contrasts.map(c => `
${escapeHtml(c.situation_a)}
${escapeHtml(c.example_a)}
${escapeHtml(c.trans_a)}
${escapeHtml(c.situation_b)}
${escapeHtml(c.example_b)}
${escapeHtml(c.trans_b)}
💡 Ключевая разница: ${escapeHtml(c.key_difference)}
`).join('')}
✍️ Я записал формулу и пример в бумажную тетрадь
Перейти к закреплению на упражнениях ➔
`;
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 = `
📘
Тема успешно освоена!
Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}
Результат практики: ${correctCount} из ${selectedExercises.length} (${score}%)
Перейти к следующему блоку ➔
`;
return;
}
const ex = selectedExercises[exIdx];
container.innerHTML = `
${escapeHtml(ex.sentence)}
${ex.clue ? `
💡 Подсказка: ${escapeHtml(ex.clue)}
` : ''}
${(ex.options || []).map((opt, oIdx) => `
${oIdx + 1}
${escapeHtml(opt)}
`).join('')}
`;
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 = `
${isCorrect ? '✅ Верно!' : '❌ Ошибка в форме'}
${escapeHtml(explanation)}
${exIdx + 1 < selectedExercises.length ? 'Следующее задание ➔' : 'Завершить практику темы ➔'}
`;
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 = `
🎧
Аудирование завершено!
Результат: ${correctCount} из ${totalScenarios} (${score}%)
Перейти к следующему блоку ➔
`;
return;
}
const data = await api.listening.getExercise(step.topic || 'all');
const scenario = data.scenario;
container.innerHTML = `
▶ Послушать ситуацию
Скорость: 1.0x
"${escapeHtml(scenario.question)}"
(${escapeHtml(scenario.question_ru || '')})
Проверить
`;
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 = `
${escapeHtml(res.feedback)}
Правильный ответ: ${escapeHtml(scenario.ideal_answer)}
Показать полный текст истории (транскрипт)
EN: ${escapeHtml(scenario.story)}
RU: ${escapeHtml(scenario.story_ru)}
${scenarioIdx + 1 < totalScenarios ? 'Следующая ситуация ➔' : 'Завершить аудирование ➔'}
`;
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 = `
${escapeHtml(article.title)}
${escapeHtml(article.title_ru || '')}
${article.paragraphs.map((p, pIdx) => `
${p.split(' ').map(w => `${escapeHtml(w)} `).join(' ')}
👁️ Показать перевод абзаца
${escapeHtml(article.paragraphs_ru?.[pIdx] || '')}
`).join('')}
${article.vocabulary_highlights?.length ? `
💡 Ключевые выражения из статьи:
${article.vocabulary_highlights.map(vh => `
${escapeHtml(vh.term)} — ${escapeHtml(vh.trans)}
`).join('')}
` : ''}
Завершить чтение и перейти к ИИ-Итогу ➔
`;
// 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 = `
🤖 Локальный ИИ Qwen 2.5 формирует ваш персональный разбор...
Анализируем правильные ответы, паттерны ошибок и подбираем рекомендации по грамматике.
`;
// 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 = `
📊 Результаты по блокам урока:
${session.stepResults.map(s => `
`).join('')}
${report.strengths?.length ? `
🌟 Что получилось лучше всего:
${report.strengths.map(st => `${escapeHtml(st)} `).join('')}
` : ''}
${report.error_patterns?.length ? `
🔍 Педагогический анализ ошибок:
${report.error_patterns.map(ep => `
`).join('')}
` : ''}
${report.tomorrow_focus?.length ? `
🎯 Персональный фокус на завтра:
${report.tomorrow_focus.map(tf => `👉 ${escapeHtml(tf)} `).join('')}
` : ''}
🏁 Завершить урок и вернуться в меню
`;
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 = `История комплексных уроков пока пуста. Пройдите ваш первый урок!
`;
} else {
list.innerHTML = data.history.map(h => `
${h.grade} (${h.total_score}%)
⏱️ ${Math.round((h.duration_seconds || 0) / 60)} мин
❌ ${h.errors_count} ошибок
${h.headline ? `
"${escapeHtml(h.headline)}"
` : ''}
`).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');
}
}