diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 39efdcf..830c942 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -317,6 +317,64 @@ const SQL = { FROM exam_topics WHERE exam_key = ? AND slug = ? `), + + /* ── Weak topics (F8) ──────────────────────────────────────── + Subtopics where the user has ≥3 attempts and accuracy < 60%. + Sorted by (accuracy ASC, attempts DESC). */ + weakTopics: db.prepare(` + SELECT + tp.slug, tp.title, tp.parent_slug, + COUNT(a.id) AS attempts, + COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct, + COUNT(DISTINCT t.id) AS total_tasks, + COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved_tasks + FROM exam_topics tp + JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key + JOIN exam_attempts a ON a.exam_task_id = t.id + AND a.user_id = ? + AND a.is_correct IS NOT NULL + WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL + GROUP BY tp.slug + HAVING attempts >= 3 + AND (CAST(correct AS REAL) / attempts) < 0.6 + ORDER BY (CAST(correct AS REAL) / attempts) ASC, attempts DESC + LIMIT 3 + `), + + weakTopicSlugs: db.prepare(` + SELECT tp.slug + FROM exam_topics tp + JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key + JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct IS NOT NULL + WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL + GROUP BY tp.slug + HAVING COUNT(a.id) >= 3 + AND (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) < 0.6 + ORDER BY (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) ASC, + COUNT(a.id) DESC + LIMIT 3 + `), + + /* Practice batch — WEAK strategy: pick random tasks from top-3 weak subtopics, + skipping tasks the user has already solved correctly. */ + weakBatchTasks: (examKey, slugs, userId, count) => { + if (!slugs.length) return []; + const ph = slugs.map(() => '?').join(','); + return db.prepare(` + SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, + answer, solution_html, topic, subtopic, difficulty + FROM exam_tasks t + WHERE t.exam_key = ? + AND t.subtopic IN (${ph}) + AND t.task_type IN ('mc','open') + AND NOT EXISTS ( + SELECT 1 FROM exam_attempts a + WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1 + ) + ORDER BY RANDOM() + LIMIT ? + `).all(examKey, ...slugs, userId, count); + }, }; /* ── GET /api/exam-prep/tracks ── @@ -435,11 +493,26 @@ router.get('/:examKey/practice/next', (req, res) => { let count = Number(req.query.count) || 10; count = Math.max(1, Math.min(count, 30)); - const strategy = req.query.strategy === 'unsolved' ? 'unsolved' : 'random'; + const strategyRaw = req.query.strategy; + let strategy = ['unsolved', 'weak', 'random'].includes(strategyRaw) ? strategyRaw : 'random'; let rows; - if (strategy === 'unsolved') { + let weakSlugs = null; + + if (strategy === 'weak') { + weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug); + if (weakSlugs.length === 0) { + // No weak topics yet → fall back to unsolved so the button still works + rows = SQL.practiceUnsolved.all(examKey, req.user.id, count); + strategy = 'unsolved-fallback'; + } else { + rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count); + if (!rows.length) { + // weak topics exist but all unsolved tasks exhausted → fallback to any from those topics + rows = SQL.weakBatchTasks(examKey, weakSlugs, -1 /* never matches */, count); + } + } + } else if (strategy === 'unsolved') { rows = SQL.practiceUnsolved.all(examKey, req.user.id, count); - // Fallback: if user has solved everything, supply random anyway so UX isn't a dead-end. if (!rows.length) rows = SQL.practiceRandom.all(examKey, count); } else { rows = SQL.practiceRandom.all(examKey, count); @@ -447,7 +520,8 @@ router.get('/:examKey/practice/next', (req, res) => { res.json({ strategy, - session_id: Date.now(), // ephemeral grouping key for these N attempts + weak_slugs: weakSlugs, + session_id: Date.now(), tasks: rows.map(shapeTask), }); }); @@ -556,6 +630,7 @@ router.get('/:examKey/dashboard', (req, res) => { const recent = SQL.recentAttempts.all(req.user.id, examKey, 8); const heat = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo); const mocks = SQL.recentMocks.all(req.user.id, examKey, 3); + const weak = SQL.weakTopics.all(req.user.id, examKey); res.json({ streak, @@ -564,6 +639,16 @@ router.get('/:examKey/dashboard', (req, res) => { correct: acc7.correct, pct: acc7.attempts ? Math.round((acc7.correct / acc7.attempts) * 100) : null, }, + weak_topics: weak.map(w => ({ + slug: w.slug, + title: w.title, + parent: w.parent_slug, + attempts: w.attempts, + correct: w.correct, + accuracy: Math.round((w.correct / w.attempts) * 100), + total_tasks: w.total_tasks, + solved_tasks: w.solved_tasks, + })), recent_attempts: recent.map(r => ({ task_id: r.exam_task_id, variant: r.variant, diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 669d486..7211188 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -800,6 +800,49 @@ width: 12px; height: 12px; aspect-ratio: 1; } +/* Weak topics widget */ +.dh-weak-rows { display: flex; flex-direction: column; gap: 8px; } +.dh-weak-row { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 12px; + padding: 11px 14px; + border: 1.5px solid var(--border); + border-radius: 10px; + text-decoration: none; + color: var(--text); + background: var(--surface); + transition: all .15s; +} +.dh-weak-row:hover { + border-color: #E63946; + background: rgba(230,57,70,.04); +} +.dh-weak-info { min-width: 0; } +.dh-weak-title { + font-family: 'Unbounded', sans-serif; font-size: .9rem; font-weight: 700; +} +.dh-weak-meta { + font-size: .75rem; color: var(--text-3); margin-top: 2px; +} +.dh-weak-accuracy { + font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; + color: #E63946; + padding: 4px 10px; border-radius: 7px; + background: rgba(230,57,70,.10); +} +.dh-weak-cta { + display: inline-flex; align-items: center; gap: 4px; + font-size: .78rem; font-weight: 700; color: var(--violet); + white-space: nowrap; +} +.dh-weak-cta svg { width: 13px; height: 13px; } +@media (max-width: 540px) { + .dh-weak-row { grid-template-columns: 1fr auto; } + .dh-weak-cta { display: none; } +} + /* Recent mocks rows */ .dh-mock-row { display: grid; diff --git a/frontend/js/exam-prep/dashboard.js b/frontend/js/exam-prep/dashboard.js index c234578..cab099e 100644 --- a/frontend/js/exam-prep/dashboard.js +++ b/frontend/js/exam-prep/dashboard.js @@ -104,9 +104,9 @@ -
+

Слабые темы

-
Топ-3 темы с худшей точностью появятся после F6 (тегирование) и F8.
+
 
`; @@ -120,6 +120,7 @@ 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(); } @@ -397,6 +398,37 @@ function formatRuDate(iso) { 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; diff --git a/frontend/js/exam-prep/practice.js b/frontend/js/exam-prep/practice.js index 0943da5..f342678 100644 --- a/frontend/js/exam-prep/practice.js +++ b/frontend/js/exam-prep/practice.js @@ -13,11 +13,16 @@ // Per-session state let batch = null; // { strategy, session_id, tasks: [...] } - let strategy = readPersistedStrategy() || 'random'; + let strategy = readStrategyFromUrl() || readPersistedStrategy() || 'random'; let count = readPersistedCount(); let finalized = false; const results = new Map(); // taskId -> { isCorrect: 0|1|null, attempts: number } + function readStrategyFromUrl() { + const m = location.search.match(/[?&]strategy=(random|unsolved|weak)/); + return m ? m[1] : null; + } + /* ── Strategy persistence (local pref) ──────────────────────── */ function readPersistedStrategy() { try { return localStorage.getItem(`exam_prep_${examKey}_practice_strategy`); } catch { return null; } @@ -52,7 +57,8 @@ -
@@ -192,7 +198,7 @@
Стратегия
-
${strategy === 'unsolved' ? 'Нерешённые' : 'Случайные'}
+
${strategyLabel(strategy)}
@@ -233,4 +239,11 @@ function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); } + + function strategyLabel(s) { + if (s === 'unsolved') return 'Нерешённые'; + if (s === 'weak') return 'Слабые темы'; + if (s === 'unsolved-fallback') return 'Нерешённые (fallback)'; + return 'Случайные'; + } })();