feat(geom9 ch3 wave2 + final): §12 «Герон» + Финал Главы 3

This commit is contained in:
Maxim Dolgolyov
2026-05-29 10:13:29 +03:00
parent 8dcd54d206
commit 1b79965fce
13 changed files with 1320 additions and 10 deletions
+42
View File
@@ -0,0 +1,42 @@
'use strict';
/* ──────────────────────────────────────────────────────────────────
Exam Preparation Module — API wrappers
Thin LS.api wrappers under window.EP.api.*
────────────────────────────────────────────────────────────────── */
(function () {
const base = (examKey) => `/api/exam-prep/${encodeURIComponent(examKey)}`;
const api = {
/* Track registry / metadata */
listTracks: () => LS.api('/api/exam-prep/tracks'),
getInfo: (examKey) => LS.api(`${base(examKey)}/info`),
/* Future endpoints (F2-F10) — placeholders so calling code can be written
against the final shape. Wire them up as routes ship. */
listVariants: (examKey) => LS.api(`${base(examKey)}/variants`),
getVariant: (examKey, n) => LS.api(`${base(examKey)}/variants/${n}/tasks`),
listTopics: (examKey) => LS.api(`${base(examKey)}/topics`),
getTopicTasks:(examKey, slug, query) => LS.api(`${base(examKey)}/topics/${encodeURIComponent(slug)}/tasks${qs(query)}`),
getPracticeNext: (examKey, query) => LS.api(`${base(examKey)}/practice/next${qs(query)}`),
getDashboard: (examKey) => LS.api(`${base(examKey)}/dashboard`),
getPlan: (examKey) => LS.api(`${base(examKey)}/plan`),
savePlan: (examKey, body) => LS.api(`${base(examKey)}/plan`, { method: 'PUT', body }),
saveAttempt: (body) => LS.api(`/api/exam-prep/attempts`, { method: 'POST', body }),
startMock: (examKey, body) => LS.api(`${base(examKey)}/mock/start`, { method: 'POST', body }),
mockAnswer: (mockId, body) => LS.api(`/api/exam-prep/mock/${mockId}/answer`, { method: 'POST', body }),
mockFinish: (mockId) => LS.api(`/api/exam-prep/mock/${mockId}/finish`, { method: 'POST' }),
mockResult: (mockId) => LS.api(`/api/exam-prep/mock/${mockId}/result`),
};
function qs(obj) {
if (!obj || typeof obj !== 'object') return '';
const parts = Object.entries(obj)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
return parts.length ? `?${parts.join('&')}` : '';
}
window.EP = window.EP || {};
window.EP.api = api;
})();
+92
View File
@@ -0,0 +1,92 @@
'use strict';
/* ──────────────────────────────────────────────────────────────────
Exam Preparation Module — common helpers
Loaded by every exam-prep*.html page before its view-specific JS.
Responsibilities:
- Parse examKey from URL path: /exam-prep/<key>[/...]
- Determine current view (dashboard | variants | practice | topics | mock)
- Render the tabs bar with the active tab highlighted
- Expose helpers on window.EP
────────────────────────────────────────────────────────────────── */
(function () {
const VIEWS = [
{ id: 'dashboard', label: 'Дашборд', icon: 'gauge', path: '' },
{ id: 'variants', label: 'Варианты', icon: 'layout-grid', path: '/variants' },
{ id: 'practice', label: 'Тренажёр', icon: 'dumbbell', path: '/practice' },
{ id: 'topics', label: 'Темы', icon: 'tag', path: '/topics' },
{ id: 'mock', label: 'Пробник', icon: 'timer', path: '/mock' },
];
/* Parse examKey and view from `/exam-prep/<key>[/<view>[/...]]` */
function parseUrl() {
const parts = location.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
// parts[0] === 'exam-prep'; parts[1] === examKey; parts[2] === optional view
const examKey = parts[1] || 'math9';
const view = (parts[2] && VIEWS.find(v => v.id === parts[2]))
? parts[2]
: 'dashboard';
return { examKey, view };
}
function renderTabs(containerSel, { examKey, view }) {
const el = document.querySelector(containerSel);
if (!el) return;
el.innerHTML = VIEWS.map(v => {
const href = `/exam-prep/${examKey}${v.path}`;
const active = v.id === view ? ' active' : '';
return `<a class="ep-tab${active}" href="${href}">
<i data-lucide="${v.icon}"></i><span>${v.label}</span>
</a>`;
}).join('');
if (window.lucide && typeof lucide.createIcons === 'function') lucide.createIcons();
}
/* Bootstrap shared for every exam-prep page.
- Reads {examKey, view}
- Initializes LS auth/page chrome
- Renders the tabs bar (if a #ep-tabs slot exists)
- Loads track info and writes it into #ep-title / #ep-sub if present
- Returns the {track, counts, progress} payload to the caller (Promise) */
async function boot(opts = {}) {
const { examKey, view } = parseUrl();
if (typeof LS !== 'undefined') {
LS.initPage?.();
LS.showBoardIfAllowed?.();
LS.hideDisabledFeatures?.();
}
renderTabs(opts.tabsSelector || '#ep-tabs', { examKey, view });
let info = null;
try {
info = await LS.api(`/api/exam-prep/${examKey}/info`);
} catch (e) {
console.warn('[exam-prep] info failed', e);
}
if (info?.track) {
const titleEl = document.getElementById('ep-title');
const subEl = document.getElementById('ep-sub');
if (titleEl) titleEl.textContent = info.track.title;
if (subEl) {
const c = info.counts || {};
subEl.textContent =
`${info.track.variants_count} вариантов · ${c.total ?? '—'} задач · ` +
`${info.track.duration_min} мин`;
}
}
window.EP = window.EP || {};
window.EP.examKey = examKey;
window.EP.view = view;
window.EP.info = info;
return { examKey, view, info };
}
window.EP = {
boot, parseUrl, renderTabs, VIEWS,
};
})();
+107
View File
@@ -0,0 +1,107 @@
'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.
────────────────────────────────────────────────────────────────── */
(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 accuracy = progress.total_attempts
? Math.round((progress.correct_attempts / progress.total_attempts) * 100)
: null;
main.innerHTML = `
<div class="ep-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 ${accuracy == null ? '' : accuracy >= 70 ? 'ep-good' : 'ep-warn'}">${accuracy == null ? '—' : accuracy + '%'}</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>
<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="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();
})();
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
function stripTags(s) {
return String(s || '').replace(/<[^>]+>/g, '');
}