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

279 lines
13 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 in parallel with rendering shell.
const dashPromise = EP.api.getDashboard(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="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" style="opacity:.7">
<h3>Слабые темы</h3>
<div class="ep-card-hint">Топ-3 темы с худшей точностью появятся после F6 (тегирование) и F8.</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);
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>`;
}
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, '');
}