'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} попыток
Серия дней
 
Точность 7 дней
 

С чего начать

${escapeHtml(stripTags(track.intro_html || ''))}
Тренировка Все варианты Пробный экзамен
 

Последние попытки

 

Активность · 28 дней

 

Пробники

 

Банк задач

Всего ${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 ? `
Дата экзамена в прошлом. Перенесите план или удалите его.
` : ''}
Сделать ${Math.max(1, tgt - doneToday)} задач сейчас
`; 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 = `
${items.map(w => { const tasksLeft = Math.max(0, w.total_tasks - w.solved_tasks); return `
${escapeHtml(w.title)}
${w.correct}/${w.attempts} попыток · ${tasksLeft} задач не взято
${w.accuracy}%
Прокачать
`; }).join('')}
Тренировать слабые темы
`; } 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, ''); }