'use strict';
/* ──────────────────────────────────────────────────────────────────
TaskCard component — reusable across variants / practice / topic /
mock views. Renders ONE task with answer input, check button, and
solution toggle.
Public API:
EP.TaskCard.render(container, task, opts)
container : HTMLElement
task : { id, idx, type, text, figure, opts, answer, solution }
opts : {
mode : 'variant'|'practice'|'topic'|'mock'
sessionId : number | null (groups attempts in a session)
autoCheck : true (whether check button is shown; mock=false)
showSolution : true (whether solution toggle exists; mock=false)
onAttempt : (result) => void (notify parent after check or solution-view)
numbering : number | null (override task number badge)
}
────────────────────────────────────────────────────────────────── */
(function () {
const ICONS = {
chev: '',
check: '',
cross: '',
rotate: '',
};
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
}
function buildOptsBlock(taskId, opts) {
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts';
const name = `tc-opt-${taskId}`;
return `
` + opts.map(([l, t]) => `
`).join('') + `
`;
}
/* Render one task into `container`. Returns a controller object with .destroy(). */
function render(container, task, opts = {}) {
const mode = opts.mode || 'variant';
const showAns = opts.autoCheck !== false;
const showSol = opts.showSolution !== false;
const numbering = (opts.numbering != null) ? opts.numbering : task.idx;
const sessionId = opts.sessionId || null;
const onAttempt = opts.onAttempt || (() => {});
const card = document.createElement('div');
card.className = 'tc-card';
card.dataset.taskId = String(task.id);
card.dataset.taskType = task.type;
// ── Inner skeleton
let inputBlock = '';
if (showAns) {
if (task.type === 'mc' && task.opts) {
inputBlock = `
${buildOptsBlock(task.id, task.opts)}
`;
} else if (task.type === 'open') {
inputBlock = `
`;
} else if (task.type === 'long') {
inputBlock = `
Развёрнутый ответ — проверьте себя по решению, затем отметьте:
`;
}
}
const solBlock = (showSol && task.solution) ? `
${task.solution}
` : '';
card.innerHTML = `
${numbering}
Задание ${numbering}
${typeLabel(task.type)}
${task.text}
${task.figure ? `
${task.figure}
` : ''}
${inputBlock}
${solBlock}
`;
container.appendChild(card);
// ── KaTeX render
EP.katex?.run(card);
// ── State
let startedAt = Date.now();
let solutionLogged = false;
let solutionRendered = false;
let attemptCount = 0; // how many CHECK attempts made
let firstAttemptCorrect = null; // we report this in onAttempt
// ── Input enable on first interaction
const inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]');
const checkBtn = card.querySelector('[data-tc-check]');
inputs.forEach(inp => {
inp.addEventListener('input', () => updateCheckEnabled());
inp.addEventListener('change', () => updateCheckEnabled());
});
function updateCheckEnabled() {
if (!checkBtn) return;
const has = readUserAnswer() !== null;
checkBtn.disabled = !has || card.dataset.tcLocked === '1';
}
function readUserAnswer() {
const mcGroup = card.querySelector('[data-tc-mc]');
if (mcGroup) {
const picked = mcGroup.querySelector('input[type="radio"]:checked');
return picked ? picked.value : null;
}
const text = card.querySelector('[data-tc-text]');
if (text) {
const v = text.value.trim();
return v || null;
}
return null;
}
// ── Check action
if (checkBtn) {
checkBtn.addEventListener('click', () => {
const userAnswer = readUserAnswer();
if (userAnswer == null) return;
const isCorrect = EP.answer.check(userAnswer, task.answer) ? 1 : 0;
attemptCount++;
if (firstAttemptCorrect === null) firstAttemptCorrect = isCorrect;
applyVerdict(isCorrect, userAnswer);
sendAttempt({ user_answer: userAnswer, is_correct: isCorrect });
onAttempt({ taskId: task.id, isCorrect, userAnswer, attempt: attemptCount });
});
}
function applyVerdict(isCorrect, userAnswer) {
const verdict = card.querySelector('[data-tc-verdict]');
card.classList.remove('tc-correct', 'tc-wrong');
card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong');
if (verdict) {
verdict.hidden = false;
verdict.innerHTML = isCorrect
? `${ICONS.check} Правильно`
: `${ICONS.cross} Неправильно
`;
}
if (isCorrect) {
// Lock inputs + auto-open solution
card.querySelectorAll('input').forEach(el => el.disabled = true);
checkBtn.disabled = true;
card.dataset.tcLocked = '1';
autoOpenSolution();
} else {
// Allow retry
const retry = card.querySelector('[data-tc-retry]');
if (retry) retry.addEventListener('click', () => resetForRetry());
}
}
function resetForRetry() {
const verdict = card.querySelector('[data-tc-verdict]');
card.classList.remove('tc-wrong');
if (verdict) { verdict.hidden = true; verdict.innerHTML = ''; }
startedAt = Date.now();
}
function autoOpenSolution() {
const btn = card.querySelector('[data-tc-sol]');
const panel = card.querySelector('[data-tc-sol-panel]');
if (btn && panel && !panel.classList.contains('visible')) {
toggleSolution(btn, panel);
}
}
// ── Solution toggle (manual)
const solBtn = card.querySelector('[data-tc-sol]');
const solPanel = card.querySelector('[data-tc-sol-panel]');
if (solBtn && solPanel) {
solBtn.addEventListener('click', () => toggleSolution(solBtn, solPanel));
}
function toggleSolution(btn, panel) {
const open = panel.classList.contains('visible');
panel.classList.toggle('visible', !open);
btn.classList.toggle('open', !open);
btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение';
if (!open) {
if (!solutionRendered) {
EP.katex?.run(panel);
solutionRendered = true;
}
if (!solutionLogged) {
solutionLogged = true;
sendAttempt({ solution_viewed: 1 });
onAttempt({ taskId: task.id, solutionViewed: true });
}
}
}
// ── Long: self-mark buttons
card.querySelectorAll('[data-tc-self]').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.dataset.tcSelf === '1' ? 1 : 0;
attemptCount++;
if (firstAttemptCorrect === null) firstAttemptCorrect = val;
card.classList.remove('tc-correct', 'tc-wrong');
card.classList.add(val ? 'tc-correct' : 'tc-wrong');
card.querySelectorAll('[data-tc-self]').forEach(b => b.disabled = true);
sendAttempt({ is_correct: val });
onAttempt({ taskId: task.id, isCorrect: val, attempt: attemptCount });
});
});
function sendAttempt(partial) {
const body = Object.assign({
exam_task_id: task.id,
mode,
session_id: sessionId,
time_ms: Math.min(Date.now() - startedAt, 24 * 3600 * 1000),
user_answer: null,
is_correct: null,
hint_used: 0,
solution_viewed:0,
}, partial);
// Best-effort — UI doesn't block on this
EP.api.saveAttempt(body).catch(() => {});
}
return {
el: card,
destroy: () => card.remove(),
};
}
function typeLabel(type) {
if (type === 'mc') return 'выбор';
if (type === 'open') return 'кр. ответ';
return 'развёрнут.';
}
window.EP = window.EP || {};
window.EP.TaskCard = { render };
})();