Files
Learn_System/frontend/js/exam-prep/dashboard.js
T

494 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ──────────────────────────────────────────────────────────────────
Dashboard view — landing screen of /exam-prep/:examKey
F1: track info + global counts + cumulative progress
F4: streak, last attempts, 7d accuracy, activity heatmap, recent mocks
────────────────────────────────────────────────────────────────── */
(async function () {
const { info } = await EP.boot();
const main = document.getElementById('ep-main');
if (!info?.track) {
main.innerHTML = `<div class="ep-empty">
<i data-lucide="alert-triangle"></i>
<h4>Не удалось загрузить данные экзамена</h4>
<p>Проверьте, что миграция применена и трек math9 включён.</p>
</div>`;
if (window.lucide) lucide.createIcons();
return;
}
const { track, counts, progress } = info;
const solvedPct = counts.total
? Math.round((progress.tasks_solved / counts.total) * 100)
: 0;
const accAll = progress.total_attempts
? Math.round((progress.correct_attempts / progress.total_attempts) * 100)
: null;
// Fetch F4 live aggregates + F10 plan in parallel with rendering shell.
const dashPromise = EP.api.getDashboard(track.exam_key).catch(() => null);
const planPromise = EP.api.getPlan(track.exam_key).catch(() => null);
main.innerHTML = `
<div class="ep-stats" id="dh-top-stats">
<div class="ep-stat">
<div class="ep-stat-label">Решено задач</div>
<div class="ep-stat-value ep-violet">${progress.tasks_solved} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${counts.total}</span></div>
<div class="ep-bar"><div class="ep-bar-fill" style="width:${solvedPct}%"></div></div>
<div class="ep-stat-sub">${solvedPct}% от банка</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Точность (всё время)</div>
<div class="ep-stat-value ${accAll == null ? '' : accAll >= 70 ? 'ep-good' : 'ep-warn'}">${accAll == null ? '—' : accAll + '%'}</div>
<div class="ep-stat-sub">${progress.correct_attempts} верно из ${progress.total_attempts} попыток</div>
</div>
<div class="ep-stat" id="dh-streak"><div class="ep-stat-label">Серия дней</div><div class="ep-stat-value"><span class="ep-skel" style="width:42px;height:1em">&nbsp;</span></div></div>
<div class="ep-stat" id="dh-acc7"><div class="ep-stat-label">Точность 7 дней</div><div class="ep-stat-value"><span class="ep-skel" style="width:42px;height:1em">&nbsp;</span></div></div>
</div>
<div class="ep-card">
<h3>С чего начать</h3>
<div class="ep-card-hint">${escapeHtml(stripTags(track.intro_html || ''))}</div>
<div class="ep-cta-row">
<a class="ep-btn ep-btn-primary" href="/exam-prep/${track.exam_key}/practice">
<i data-lucide="play"></i> Тренировка
</a>
<a class="ep-btn" href="/exam-prep/${track.exam_key}/variants">
<i data-lucide="layout-grid"></i> Все варианты
</a>
<a class="ep-btn" href="/exam-prep/${track.exam_key}/mock">
<i data-lucide="timer"></i> Пробный экзамен
</a>
</div>
</div>
<div class="ep-card" id="dh-plan"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em">&nbsp;</span></div></div>
<div class="dh-row">
<div class="ep-card dh-recent">
<h3>Последние попытки</h3>
<div id="dh-recent-list" class="dh-recent-list"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em">&nbsp;</span></div></div>
</div>
<div class="ep-card dh-heatmap-card">
<h3>Активность · 28 дней</h3>
<div id="dh-heatmap"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em">&nbsp;</span></div></div>
</div>
</div>
<div class="ep-card" id="dh-mocks">
<h3>Пробники</h3>
<div id="dh-mocks-list"><div class="ep-empty" style="padding:24px"><span class="ep-skel" style="width:200px;height:1em">&nbsp;</span></div></div>
</div>
<div class="ep-card">
<h3>Банк задач</h3>
<div class="ep-card-hint">Всего ${counts.total} задач в ${track.variants_count} вариантах.</div>
<div class="ep-stats" style="margin-bottom:0">
<div class="ep-stat">
<div class="ep-stat-label">Тестовая часть (А)</div>
<div class="ep-stat-value">${counts.mc}</div>
<div class="ep-stat-sub">выбор варианта а–д</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Краткий ответ</div>
<div class="ep-stat-value">${counts.open}</div>
<div class="ep-stat-sub">число / дробь / пара</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Развёрнутые</div>
<div class="ep-stat-value">${counts.long}</div>
<div class="ep-stat-sub">выражения, графики</div>
</div>
</div>
</div>
<div class="ep-card" id="dh-weak">
<h3>Слабые темы</h3>
<div id="dh-weak-list"><div class="ep-empty" style="padding:20px"><span class="ep-skel" style="width:200px;height:1em">&nbsp;</span></div></div>
</div>
`;
if (window.lucide) lucide.createIcons();
// Stitch in live aggregates once they arrive.
const dash = await dashPromise;
if (dash) {
renderStreak(dash.streak);
renderAcc7(dash.accuracy_7d);
renderRecent(dash.recent_attempts, track.exam_key);
renderHeatmap(dash.heatmap);
renderMocks(dash.recent_mocks, track.exam_key);
renderWeakTopics(dash.weak_topics || [], track.exam_key);
if (window.lucide) lucide.createIcons();
}
// F10: plan widget
const planPayload = await planPromise;
renderPlanWidget(track.exam_key, planPayload?.plan || null);
if (window.lucide) lucide.createIcons();
})();
/* ════════════════════════════════════════════════════════════════
Widgets
════════════════════════════════════════════════════════════════ */
function renderStreak(streak) {
const el = document.getElementById('dh-streak');
if (!el) return;
const label = streak === 0 ? 'Нет серии'
: streak === 1 ? '1 день подряд'
: `${streak} ${pluralRu(streak, ['день', 'дня', 'дней'])} подряд`;
el.innerHTML = `
<div class="ep-stat-label">Серия</div>
<div class="ep-stat-value ${streak >= 3 ? 'ep-good' : ''}">${streak}</div>
<div class="ep-stat-sub">${label}${streak >= 3 ? ' · так держать!' : ''}</div>`;
}
function renderAcc7(a) {
const el = document.getElementById('dh-acc7');
if (!el) return;
const valClass = a.pct == null ? '' : a.pct >= 70 ? 'ep-good' : 'ep-warn';
el.innerHTML = `
<div class="ep-stat-label">Точность 7 дней</div>
<div class="ep-stat-value ${valClass}">${a.pct == null ? '—' : a.pct + '%'}</div>
<div class="ep-stat-sub">${a.correct} верно из ${a.attempts} попыток</div>`;
}
function renderRecent(items, examKey) {
const el = document.getElementById('dh-recent-list');
if (!el) return;
if (!items.length) {
el.innerHTML = `<div class="ep-empty" style="padding:20px">
<i data-lucide="hourglass"></i>
<p>Ещё нет попыток. Откройте <a href="/exam-prep/${examKey}/practice" style="color:var(--violet)">тренажёр</a>.</p>
</div>`;
return;
}
el.innerHTML = items.map(it => {
const mark = it.is_correct === 1 ? '<span class="dh-mark dh-mark-ok">✓</span>'
: it.is_correct === 0 ? '<span class="dh-mark dh-mark-bad">✗</span>'
: it.solution_viewed ? '<span class="dh-mark dh-mark-view">i</span>'
: '<span class="dh-mark dh-mark-pending">·</span>';
const when = relativeTime(it.created_at);
const modeLabel = it.mode === 'mock' ? 'Пробник'
: it.mode === 'variant' ? 'Вариант'
: it.mode === 'practice' ? 'Тренажёр'
: it.mode === 'topic' ? 'Тема'
: '';
const variantBadge = it.variant != null
? `<a class="dh-recent-loc" href="/exam-prep/${examKey}/variants?v=${it.variant}" title="Открыть вариант ${it.variant}">В${it.variant}·№${it.task_idx}</a>`
: `<span class="dh-recent-loc">№${it.task_idx}</span>`;
return `<div class="dh-recent-item">
${mark}
${variantBadge}
<div class="dh-recent-preview">${escapeHtml(it.preview)}</div>
<div class="dh-recent-meta"><span class="dh-mode">${modeLabel}</span><span class="dh-time">${when}</span></div>
</div>`;
}).join('');
}
function renderHeatmap(items) {
const el = document.getElementById('dh-heatmap');
if (!el) return;
// Build 28 cells ending today.
const byDay = new Map(items.map(d => [d.day, d]));
const cells = [];
const today = new Date();
for (let i = 27; i >= 0; i--) {
const d = new Date(today);
d.setUTCDate(today.getUTCDate() - i);
const key = toIsoDate(d);
const v = byDay.get(key);
const a = v ? v.attempts : 0;
let lvl = 0;
if (a >= 1) lvl = 1;
if (a >= 5) lvl = 2;
if (a >= 12) lvl = 3;
if (a >= 25) lvl = 4;
const tip = v ? `${key}: ${v.attempts} попыток, ${v.correct} верно` : `${key}: 0`;
cells.push(`<div class="dh-cell dh-l${lvl}" title="${tip}"></div>`);
}
el.innerHTML = `
<div class="dh-heatmap">${cells.join('')}</div>
<div class="dh-heatmap-legend">
<span>меньше</span>
<span class="dh-cell dh-l0"></span>
<span class="dh-cell dh-l1"></span>
<span class="dh-cell dh-l2"></span>
<span class="dh-cell dh-l3"></span>
<span class="dh-cell dh-l4"></span>
<span>больше</span>
</div>`;
}
/* ─── Plan widget (F10) ──────────────────────────────────────── */
function renderPlanWidget(examKey, plan) {
const el = document.getElementById('dh-plan');
if (!el) return;
if (!plan) {
el.innerHTML = `
<h3>План подготовки</h3>
<div class="ep-card-hint">Укажите дату экзамена — посчитаем, сколько задач нужно делать в день, чтобы успеть.</div>
<div class="ep-cta-row">
<button class="ep-btn ep-btn-primary" id="dh-plan-create">
<i data-lucide="calendar-plus"></i> Создать план
</button>
</div>`;
el.querySelector('#dh-plan-create').onclick = () => openPlanModal(examKey, null);
return;
}
const daysLeft = plan.days_left;
const tasksLeft = plan.tasks_left;
const tgt = plan.daily_target || 10;
const doneToday = plan.today.solved;
const pctToday = tgt ? Math.min(100, Math.round((doneToday / tgt) * 100)) : 0;
const daysClass = daysLeft <= 7 ? 'ep-warn' : daysLeft <= 30 ? 'ep-violet' : '';
const daysLabel = daysLeft < 0
? `${Math.abs(daysLeft)} ${pluralRu(Math.abs(daysLeft), ['день','дня','дней'])} назад`
: daysLeft === 0
? 'сегодня экзамен'
: `${daysLeft} ${pluralRu(daysLeft, ['день','дня','дней'])}`;
const examDateFmt = formatRuDate(plan.exam_date);
const expired = daysLeft < 0;
el.innerHTML = `
<div class="dh-plan-head">
<h3>План подготовки</h3>
<button class="dh-plan-edit" id="dh-plan-edit" title="Изменить план">
<i data-lucide="pencil"></i>
</button>
</div>
<div class="dh-plan-grid">
<div class="dh-plan-stat">
<div class="ep-stat-label">До экзамена</div>
<div class="ep-stat-value ${daysClass}">${daysLeft < 0 ? '—' : daysLeft}</div>
<div class="ep-stat-sub">${daysLabel} · ${examDateFmt}</div>
</div>
<div class="dh-plan-stat">
<div class="ep-stat-label">Сегодня</div>
<div class="ep-stat-value ${doneToday >= tgt ? 'ep-good' : ''}">${doneToday} <span style="font-size:.7em;color:var(--text-3);font-weight:600">/ ${tgt}</span></div>
<div class="ep-bar"><div class="ep-bar-fill" style="width:${pctToday}%"></div></div>
<div class="ep-stat-sub">${doneToday >= tgt ? 'цель на сегодня выполнена!' : `осталось ${tgt - doneToday}`}</div>
</div>
<div class="dh-plan-stat">
<div class="ep-stat-label">Осталось задач</div>
<div class="ep-stat-value">${tasksLeft}</div>
<div class="ep-stat-sub">${daysLeft > 0 ? `~${Math.ceil(tasksLeft / daysLeft)} в день, чтобы успеть` : 'дата прошла'}</div>
</div>
</div>
${expired ? `<div class="dh-plan-expired">Дата экзамена в прошлом. Перенесите план или удалите его.</div>` : ''}
<div class="ep-cta-row" style="margin-top:14px">
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/practice">
<i data-lucide="play"></i> Сделать ${Math.max(1, tgt - doneToday)} задач сейчас
</a>
</div>`;
el.querySelector('#dh-plan-edit').onclick = () => openPlanModal(examKey, plan);
}
function openPlanModal(examKey, plan) {
const today = toIsoDate(new Date());
const examDate = plan?.exam_date || addDaysIso(today, 60); // default: 2 months out
const dailyTarget = plan?.daily_target || 10;
const body = `
<div class="dh-plan-form">
<div class="dh-plan-field">
<label>Дата экзамена</label>
<input type="date" id="dh-plan-date" value="${examDate}" min="${today}" class="dh-plan-input" />
</div>
<div class="dh-plan-field">
<label>Задач в день (550)</label>
<input type="number" id="dh-plan-target" value="${dailyTarget}" min="5" max="50" class="dh-plan-input" />
<div class="dh-plan-hint">Если оставить пусто или вне диапазона — посчитаем автоматом.</div>
</div>
<div class="dh-plan-field" id="dh-plan-derived"></div>
</div>`;
const actions = [
{ label: 'Отмена', onClick: () => m.close() },
];
if (plan) {
actions.push({
label: 'Удалить план',
onClick: async () => {
if (!confirm('Удалить план? Прогресс сохранится.')) return;
try {
await EP.api.deletePlan(examKey);
m.close();
location.reload();
} catch (e) {
m.setError(`Не удалось удалить: ${e.message || e}`);
}
},
});
}
actions.push({
label: plan ? 'Сохранить' : 'Создать план',
primary: true,
onClick: async () => {
const date = m.body.querySelector('#dh-plan-date').value;
const target = Number(m.body.querySelector('#dh-plan-target').value) || 10;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
m.setError('Введите дату экзамена');
return;
}
try {
await EP.api.savePlan(examKey, {
exam_date: date,
daily_target: Math.max(5, Math.min(50, target)),
});
m.close();
location.reload();
} catch (e) {
m.setError(`Не удалось сохранить: ${e.message || e}`);
}
},
});
const m = LS.modal({
title: plan ? 'Изменить план' : 'Новый план подготовки',
content: body,
size: 'sm',
actions,
});
// Live-preview derived numbers
const dateInp = m.body.querySelector('#dh-plan-date');
const tgtInp = m.body.querySelector('#dh-plan-target');
const derived = m.body.querySelector('#dh-plan-derived');
function updateDerived() {
const d = dateInp.value;
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) { derived.innerHTML = ''; return; }
const left = daysFromTodayIso(d);
const tgt = Math.max(5, Math.min(50, Number(tgtInp.value) || 10));
derived.innerHTML = `
<div class="dh-plan-derived-card">
<span>До экзамена: <b>${left < 0 ? Math.abs(left) + ' дн. назад' : left + ' ' + pluralRu(left, ['день','дня','дней'])}</b></span>
<span>При ${tgt} задач/день: <b>${left > 0 ? left * tgt : 0} задач максимум</b></span>
</div>`;
}
dateInp.oninput = updateDerived;
tgtInp.oninput = updateDerived;
updateDerived();
}
function addDaysIso(iso, n) {
const [y, m, d] = iso.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
dt.setUTCDate(dt.getUTCDate() + n);
return toIsoDate(dt);
}
function daysFromTodayIso(iso) {
const [y, m, d] = iso.split('-').map(Number);
const t = Date.UTC(y, m - 1, d);
const n = new Date();
const today = Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate());
return Math.round((t - today) / 86400000);
}
function formatRuDate(iso) {
if (!iso) return '';
const [y, m, d] = iso.split('-').map(Number);
const months = ['янв','фев','мар','апр','мая','июн','июл','авг','сен','окт','ноя','дек'];
return `${d} ${months[m - 1]} ${y}`;
}
function renderWeakTopics(items, examKey) {
const el = document.getElementById('dh-weak-list');
if (!el) return;
if (!items.length) {
el.innerHTML = `<div class="ep-empty" style="padding:20px">
<i data-lucide="check-circle-2"></i>
<p>Слабых тем не выявлено. Решите больше задач, чтобы увидеть приоритеты.</p>
</div>`;
return;
}
el.innerHTML = `
<div class="dh-weak-rows">
${items.map(w => {
const tasksLeft = Math.max(0, w.total_tasks - w.solved_tasks);
return `<a class="dh-weak-row" href="/exam-prep/${examKey}/topics/${encodeURIComponent(w.slug)}">
<div class="dh-weak-info">
<div class="dh-weak-title">${escapeHtml(w.title)}</div>
<div class="dh-weak-meta">${w.correct}/${w.attempts} попыток · ${tasksLeft} задач не взято</div>
</div>
<div class="dh-weak-accuracy">${w.accuracy}%</div>
<div class="dh-weak-cta">Прокачать <i data-lucide="arrow-right"></i></div>
</a>`;
}).join('')}
</div>
<div class="ep-cta-row" style="margin-top:14px">
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/practice?strategy=weak">
<i data-lucide="target"></i> Тренировать слабые темы
</a>
</div>`;
}
function renderMocks(items, examKey) {
const el = document.getElementById('dh-mocks-list');
if (!el) return;
if (!items.length) {
el.innerHTML = `<div class="ep-empty" style="padding:20px">
<i data-lucide="timer-off"></i>
<p>Пробников пока не было. <a href="/exam-prep/${examKey}/mock" style="color:var(--violet)">Запустить первый</a>?</p>
</div>`;
return;
}
el.innerHTML = items.map(m => {
const isActive = m.status === 'active';
const title = m.source === 'variant' ? `Вариант ${m.variant}` : `Случайные ${m.total_tasks}`;
const when = relativeTime(m.started_at);
const score = isActive
? `<span class="dh-mock-active">В процессе</span>`
: `<span class="dh-mock-score">${m.score != null ? m.score + ' баллов' : '—'} · ${m.total_correct}/${m.total_tasks}</span>`;
return `<a class="dh-mock-row" href="/exam-prep/${examKey}/mock/${m.id}">
<span class="dh-mock-title">${title}</span>
${score}
<span class="dh-mock-when">${when}</span>
</a>`;
}).join('');
}
/* ════════════════════════════════════════════════════════════════
Utils
════════════════════════════════════════════════════════════════ */
function pluralRu(n, forms) {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return forms[2];
if (mod10 === 1) return forms[0];
if (mod10 >= 2 && mod10 <= 4) return forms[1];
return forms[2];
}
function relativeTime(ts) {
if (!ts) return '';
const diff = (Date.now() - Number(ts)) / 1000;
if (diff < 60) return 'только что';
if (diff < 3600) return `${Math.floor(diff/60)} мин назад`;
if (diff < 86400) return `${Math.floor(diff/3600)} ч назад`;
if (diff < 7 * 86400) return `${Math.floor(diff/86400)} ${pluralRu(Math.floor(diff/86400), ['день','дня','дней'])} назад`;
const d = new Date(Number(ts));
return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}`;
}
function toIsoDate(d) {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
function stripTags(s) {
return String(s || '').replace(/<[^>]+>/g, '');
}