'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 in parallel with rendering shell. const dashPromise = EP.api.getDashboard(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}
выражения, графики

Слабые темы

Топ-3 темы с худшей точностью появятся после F6 (тегирование) и F8.
`; 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 = `
Серия
${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('')}
меньше больше
`; } 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, ''); }