feat(exam-prep F4): живой дашборд — streak + последние попытки + точность 7д + хитмап активности + пробники

This commit is contained in:
Maxim Dolgolyov
2026-05-29 11:12:23 +03:00
parent 2fda4ee7f6
commit 294b3622b5
3 changed files with 472 additions and 18 deletions
+189 -18
View File
@@ -1,8 +1,8 @@
'use strict';
/* ──────────────────────────────────────────────────────────────────
Dashboard view — landing screen of /exam-prep/:examKey
In F1: shows track meta + global counts + first-pass user progress.
Full live dashboard (slabnik themes, streak, plan) ships in F4 / F8 / F10.
F1: track info + global counts + cumulative progress
F4: streak, last attempts, 7d accuracy, activity heatmap, recent mocks
────────────────────────────────────────────────────────────────── */
(async function () {
@@ -23,12 +23,15 @@
const solvedPct = counts.total
? Math.round((progress.tasks_solved / counts.total) * 100)
: 0;
const accuracy = progress.total_attempts
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">
<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>
@@ -36,20 +39,12 @@
<div class="ep-stat-sub">${solvedPct}% от банка</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">Точность</div>
<div class="ep-stat-value ${accuracy == null ? '' : accuracy >= 70 ? 'ep-good' : 'ep-warn'}">${accuracy == null ? '—' : accuracy + '%'}</div>
<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">
<div class="ep-stat-label">Серия (streak)</div>
<div class="ep-stat-value">—</div>
<div class="ep-stat-sub">Будет в F4</div>
</div>
<div class="ep-stat">
<div class="ep-stat-label">До экзамена</div>
<div class="ep-stat-value">—</div>
<div class="ep-stat-sub">Задайте дату в F10</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">
@@ -57,7 +52,7 @@
<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> Начать тренировку
<i data-lucide="play"></i> Тренировка
</a>
<a class="ep-btn" href="/exam-prep/${track.exam_key}/variants">
<i data-lucide="layout-grid"></i> Все варианты
@@ -68,6 +63,22 @@
</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>
@@ -92,13 +103,173 @@
<div class="ep-card" style="opacity:.7">
<h3>Слабые темы</h3>
<div class="ep-card-hint">Топ-3 темы с худшей точностью появятся после фазы F6 (тегирование) и F8.</div>
<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]));
}