feat(trainer): P6 — учительская аналитика класса + общий прогресс

- GET /api/practice/class-stats (classStats): агрегаты по навыкам + матрица ученик×навык; доступ владелец класса/админ
- клиент: кнопка «Аналитика класса» (учителю) → модалка с тепловой картой (точность/освоено) + пикер классов; LS.practiceClassStats
- лёгкая геймификация: строка «Освоено навыков M из N · решено всего K» из агрегатов practice_progress
- тесты practice.test.js +4 (владелец видит; чужой/ученик → 403; без class_id → 400); смоук страницы 27/27; план P6 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:24:05 +03:00
parent 7cc2a9d526
commit d003a0e100
6 changed files with 222 additions and 6 deletions
+115 -2
View File
@@ -103,6 +103,32 @@
.tr-badge-n { margin-left: 7px; font-size: .7rem; font-weight: 800; color: #94a3b8; background: rgba(148,163,184,0.16); border-radius: 99px; padding: 1px 7px; }
.tr-chip.on .tr-badge-n { color: #e0e7ff; background: rgba(255,255,255,0.2); }
/* ── общий прогресс (лёгкая геймификация) ── */
.tr-overall { color: #6366f1; font-size: .84rem; font-weight: 600; margin: -2px 0 14px; }
.tr-overall:empty { display: none; }
/* ── модалка аналитики + тепловая карта ── */
.tr-modal { position: fixed; inset: 0; z-index: 50; background: rgba(15,23,42,0.5); display: flex; align-items: center; justify-content: center; padding: 20px; }
.tr-modal-card { background: #fff; border-radius: 16px; max-width: 920px; width: 100%; max-height: 86vh; overflow: auto; box-shadow: 0 24px 60px rgba(0,0,0,0.3); }
.tr-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid rgba(148,163,184,0.2); font-weight: 800; font-family: 'Manrope', sans-serif; font-size: 1.05rem; position: sticky; top: 0; background: #fff; }
.tr-modal-x { background: none; border: none; cursor: pointer; color: #64748b; padding: 4px; border-radius: 8px; }
.tr-modal-x:hover { background: rgba(148,163,184,0.15); color: #1e293b; }
.tr-modal-x .ic { width: 18px; height: 18px; }
#tr-an-body { padding: 18px 20px; }
.tr-an-picker { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.tr-an-cls { font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; padding: 7px 13px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32); background: #fff; color: #475569; }
.tr-an-cls:hover, .tr-an-cls.on { border-color: #818cf8; color: #4338ca; background: #eef2ff; }
.tr-an-empty { color: #94a3b8; padding: 20px; text-align: center; }
.tr-hm-wrap { overflow-x: auto; }
table.tr-hm { border-collapse: collapse; font-size: .8rem; }
table.tr-hm th, table.tr-hm td { border: 1px solid rgba(148,163,184,0.22); padding: 6px 8px; text-align: center; white-space: nowrap; }
table.tr-hm th { background: #f8fafc; color: #475569; font-weight: 700; position: sticky; top: 0; }
.tr-hm-name { text-align: left !important; font-weight: 600; color: #334155; background: #f8fafc; position: sticky; left: 0; }
.tr-hm-none { color: #cbd5e1; }
.tr-hm-cell { font-weight: 700; color: #334155; }
.tr-hm-cell .ic { width: 14px; height: 14px; color: #fff; }
.tr-hm-sum { font-weight: 800; color: #4f46e5; background: #eef2ff; }
/* ── режим (умная тренировка) ── */
.tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.tr-mode-btn {
@@ -145,12 +171,30 @@
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
</div>
<div class="tr-overall" id="tr-overall"></div>
<div class="tr-mode">
<button class="tr-mode-btn on" id="tr-smart-btn" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>
Умная тренировка
</button>
<span class="tr-session" id="tr-session"></span>
<button class="tr-mode-btn" id="tr-analytics-btn" type="button" style="display:none;margin-left:auto">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><rect x="7" y="10" width="3" height="7"/><rect x="13" y="6" width="3" height="11"/></svg>
Аналитика класса
</button>
</div>
<div class="tr-modal" id="tr-analytics" style="display:none">
<div class="tr-modal-card">
<div class="tr-modal-head">
<span>Аналитика класса</span>
<button class="tr-modal-x" id="tr-an-close" type="button" aria-label="Закрыть">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="tr-an-body"></div>
</div>
</div>
<div class="tr-topics" id="tr-topics"></div>
@@ -413,7 +457,7 @@
function submitAttempt(correct) {
if (!LS.practiceSubmit) return;
LS.practiceSubmit(currentSkill(), correct).then(function (r) {
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); }
if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); updateOverall(); }
}).catch(function () {});
}
@@ -515,7 +559,75 @@
updateStats();
}
// ── общий прогресс (лёгкая геймификация) ──
function updateOverall() {
var solvedTotal = 0, mastered = 0;
for (var k in prog) {
if (k === '__ms' || !Object.prototype.hasOwnProperty.call(prog, k)) continue;
var p = prog[k]; if (!p) continue;
solvedTotal += (p.solved || 0);
if (p.mastered) mastered++;
}
var el = $('tr-overall');
if (el) el.textContent = solvedTotal ? ('Освоено навыков: ' + mastered + ' из ' + gens.length + ' · решено всего: ' + solvedTotal) : '';
}
// ── учительская аналитика класса ──
var _anClasses = [], _anCur = null;
function skillTitle(id) { var g = TG.get ? TG.get(id) : null; return g ? g.title : id; }
function anPicker() {
if (_anClasses.length <= 1) return '';
return '<div class="tr-an-picker">' + _anClasses.map(function (c) {
return '<button class="tr-an-cls' + (c.id === _anCur ? ' on' : '') + '" type="button" data-cid="' + c.id + '">' + esc(c.name || ('Класс ' + c.id)) + '</button>';
}).join('') + '</div>';
}
function renderHeatmap(data) {
if (!data.skills || !data.skills.length) return '<div class="tr-an-empty">Пока нет данных — ученики ещё не решали задачи.</div>';
var head = '<tr><th>Ученик</th>' + data.skills.map(function (s) { return '<th title="' + esc(s) + '">' + esc(skillTitle(s)) + '</th>'; }).join('') + '</tr>';
var rows = data.students.map(function (st) {
var cells = data.skills.map(function (s) {
var c = st.perSkill[s];
if (!c) return '<td class="tr-hm-none">—</td>';
if (c.mastered) return '<td class="tr-hm-cell" style="background:#16a34a" title="освоено">' + ICON.star + '</td>';
var bg = c.accuracy >= 70 ? '#bbf7d0' : c.accuracy >= 40 ? '#fef9c3' : '#fecaca';
return '<td class="tr-hm-cell" style="background:' + bg + '" title="' + c.solved + ' из ' + c.attempts + '">' + c.accuracy + '%</td>';
}).join('');
return '<tr><td class="tr-hm-name">' + esc(st.name) + '</td>' + cells + '</tr>';
}).join('');
var sumCells = data.skills.map(function (s) {
var ps = data.perSkill.filter(function (x) { return x.skill === s; })[0];
return '<td class="tr-hm-sum">' + (ps ? ps.accuracy + '%' : '') + '</td>';
}).join('');
return '<div class="tr-hm-wrap"><table class="tr-hm">' + head + rows + '<tr><td class="tr-hm-name">Класс</td>' + sumCells + '</tr></table></div>';
}
function showStats(classId) {
_anCur = classId;
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Загрузка…</div>';
LS.practiceClassStats(classId).then(function (data) {
$('tr-an-body').innerHTML = anPicker() + renderHeatmap(data);
}).catch(function () {
$('tr-an-body').innerHTML = anPicker() + '<div class="tr-an-empty">Не удалось загрузить аналитику.</div>';
});
}
function openAnalytics() {
$('tr-analytics').style.display = 'flex';
$('tr-an-body').innerHTML = '<div class="tr-an-empty">Загрузка…</div>';
(LS.getClasses ? LS.getClasses() : Promise.resolve([])).then(function (r) {
var list = Array.isArray(r) ? r : (r && (r.classes || r.items)) || [];
_anClasses = list;
if (!list.length) { $('tr-an-body').innerHTML = '<div class="tr-an-empty">У вас пока нет классов.</div>'; return; }
showStats(list[0].id);
}).catch(function () { $('tr-an-body').innerHTML = '<div class="tr-an-empty">Не удалось загрузить классы.</div>'; });
}
// ── события ──
$('tr-analytics-btn').addEventListener('click', openAnalytics);
$('tr-an-close').addEventListener('click', function () { $('tr-analytics').style.display = 'none'; });
$('tr-analytics').addEventListener('click', function (e) { if (e.target === $('tr-analytics')) $('tr-analytics').style.display = 'none'; });
$('tr-an-body').addEventListener('click', function (e) {
var b = e.target.closest('.tr-an-cls'); if (!b) return;
showStats(+b.getAttribute('data-cid'));
});
$('tr-topics').addEventListener('click', function (e) {
var b = e.target.closest('.tr-chip'); if (!b) return;
var t = topics[+b.getAttribute('data-ti')]; if (!t) return;
@@ -561,7 +673,8 @@
curGen = ss[0] || gens[0];
for (var si = 0; si < ss.length; si++) { var p = prog[skillKey(ss[si])]; if (!(p && p.mastered)) { curGen = ss[si]; break; } }
if (smart) pickNext(null); // адаптивный первый навык (last=null — можно взять текущий)
renderTopics(); renderSkills(); updateSession(); newProblem();
renderTopics(); renderSkills(); updateSession(); updateOverall(); newProblem();
if (isTeacher) $('tr-analytics-btn').style.display = '';
}
(LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null))
.then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); })