'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 = `
Не удалось загрузить данные экзамена
Проверьте, что миграция применена и трек math9 включён.
`;
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 = `
Решено задач
${progress.tasks_solved} / ${counts.total}
${solvedPct}% от банка
Точность (всё время)
${accAll == null ? '—' : accAll + '%'}
${progress.correct_attempts} верно из ${progress.total_attempts} попыток
С чего начать
${escapeHtml(stripTags(track.intro_html || ''))}
Банк задач
Всего ${counts.total} задач в ${track.variants_count} вариантах.
Тестовая часть (А)
${counts.mc}
выбор варианта а–д
Краткий ответ
${counts.open}
число / дробь / пара
Развёрнутые
${counts.long}
выражения, графики
`;
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 = `
Серия
${streak}
${label}${streak >= 3 ? ' · так держать!' : ''}
`;
}
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 = `
Точность 7 дней
${a.pct == null ? '—' : a.pct + '%'}
${a.correct} верно из ${a.attempts} попыток
`;
}
function renderRecent(items, examKey) {
const el = document.getElementById('dh-recent-list');
if (!el) return;
if (!items.length) {
el.innerHTML = ``;
return;
}
el.innerHTML = items.map(it => {
const mark = it.is_correct === 1 ? '✓ '
: it.is_correct === 0 ? '✗ '
: it.solution_viewed ? 'i '
: '· ';
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
? `В${it.variant}·№${it.task_idx} `
: `№${it.task_idx} `;
return `
${mark}
${variantBadge}
${escapeHtml(it.preview)}
${modeLabel} ${when}
`;
}).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(`
`);
}
el.innerHTML = `
${cells.join('')}
меньше
больше
`;
}
/* ─── Plan widget (F10) ──────────────────────────────────────── */
function renderPlanWidget(examKey, plan) {
const el = document.getElementById('dh-plan');
if (!el) return;
if (!plan) {
el.innerHTML = `
План подготовки
Укажите дату экзамена — посчитаем, сколько задач нужно делать в день, чтобы успеть.
Создать план
`;
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 = `
План подготовки
До экзамена
${daysLeft < 0 ? '—' : daysLeft}
${daysLabel} · ${examDateFmt}
Сегодня
${doneToday} / ${tgt}
${doneToday >= tgt ? 'цель на сегодня выполнена!' : `осталось ${tgt - doneToday}`}
Осталось задач
${tasksLeft}
${daysLeft > 0 ? `~${Math.ceil(tasksLeft / daysLeft)} в день, чтобы успеть` : 'дата прошла'}
${expired ? `Дата экзамена в прошлом. Перенесите план или удалите его.
` : ''}
`;
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 = `
`;
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 = `
До экзамена: ${left < 0 ? Math.abs(left) + ' дн. назад' : left + ' ' + pluralRu(left, ['день','дня','дней'])}
При ${tgt} задач/день: ${left > 0 ? left * tgt : 0} задач максимум
`;
}
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 = `
Слабых тем не выявлено. Решите больше задач, чтобы увидеть приоритеты.
`;
return;
}
el.innerHTML = `
`;
}
function renderMocks(items, examKey) {
const el = document.getElementById('dh-mocks-list');
if (!el) return;
if (!items.length) {
el.innerHTML = ``;
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
? `В процессе `
: `${m.score != null ? m.score + ' баллов' : '—'} · ${m.total_correct}/${m.total_tasks} `;
return `
${title}
${score}
${when}
`;
}).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 => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
}
function stripTags(s) {
return String(s || '').replace(/<[^>]+>/g, '');
}