feat(exam-prep F8): слабые темы на дашборде + strategy=weak в тренажёре
This commit is contained in:
@@ -317,6 +317,64 @@ const SQL = {
|
|||||||
FROM exam_topics
|
FROM exam_topics
|
||||||
WHERE exam_key = ? AND slug = ?
|
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 ──
|
/* ── GET /api/exam-prep/tracks ──
|
||||||
@@ -435,11 +493,26 @@ router.get('/:examKey/practice/next', (req, res) => {
|
|||||||
let count = Number(req.query.count) || 10;
|
let count = Number(req.query.count) || 10;
|
||||||
count = Math.max(1, Math.min(count, 30));
|
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;
|
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);
|
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);
|
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
|
||||||
} else {
|
} else {
|
||||||
rows = SQL.practiceRandom.all(examKey, count);
|
rows = SQL.practiceRandom.all(examKey, count);
|
||||||
@@ -447,7 +520,8 @@ router.get('/:examKey/practice/next', (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
strategy,
|
strategy,
|
||||||
session_id: Date.now(), // ephemeral grouping key for these N attempts
|
weak_slugs: weakSlugs,
|
||||||
|
session_id: Date.now(),
|
||||||
tasks: rows.map(shapeTask),
|
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 recent = SQL.recentAttempts.all(req.user.id, examKey, 8);
|
||||||
const heat = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo);
|
const heat = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo);
|
||||||
const mocks = SQL.recentMocks.all(req.user.id, examKey, 3);
|
const mocks = SQL.recentMocks.all(req.user.id, examKey, 3);
|
||||||
|
const weak = SQL.weakTopics.all(req.user.id, examKey);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
streak,
|
streak,
|
||||||
@@ -564,6 +639,16 @@ router.get('/:examKey/dashboard', (req, res) => {
|
|||||||
correct: acc7.correct,
|
correct: acc7.correct,
|
||||||
pct: acc7.attempts ? Math.round((acc7.correct / acc7.attempts) * 100) : null,
|
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 => ({
|
recent_attempts: recent.map(r => ({
|
||||||
task_id: r.exam_task_id,
|
task_id: r.exam_task_id,
|
||||||
variant: r.variant,
|
variant: r.variant,
|
||||||
|
|||||||
@@ -800,6 +800,49 @@
|
|||||||
width: 12px; height: 12px; aspect-ratio: 1;
|
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 */
|
/* Recent mocks rows */
|
||||||
.dh-mock-row {
|
.dh-mock-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -104,9 +104,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ep-card" style="opacity:.7">
|
<div class="ep-card" id="dh-weak">
|
||||||
<h3>Слабые темы</h3>
|
<h3>Слабые темы</h3>
|
||||||
<div class="ep-card-hint">Топ-3 темы с худшей точностью появятся после F6 (тегирование) и F8.</div>
|
<div id="dh-weak-list"><div class="ep-empty" style="padding:20px"><span class="ep-skel" style="width:200px;height:1em"> </span></div></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
renderRecent(dash.recent_attempts, track.exam_key);
|
renderRecent(dash.recent_attempts, track.exam_key);
|
||||||
renderHeatmap(dash.heatmap);
|
renderHeatmap(dash.heatmap);
|
||||||
renderMocks(dash.recent_mocks, track.exam_key);
|
renderMocks(dash.recent_mocks, track.exam_key);
|
||||||
|
renderWeakTopics(dash.weak_topics || [], track.exam_key);
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +398,37 @@ function formatRuDate(iso) {
|
|||||||
return `${d} ${months[m - 1]} ${y}`;
|
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 = `<div class="ep-empty" style="padding:20px">
|
||||||
|
<i data-lucide="check-circle-2"></i>
|
||||||
|
<p>Слабых тем не выявлено. Решите больше задач, чтобы увидеть приоритеты.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="dh-weak-rows">
|
||||||
|
${items.map(w => {
|
||||||
|
const tasksLeft = Math.max(0, w.total_tasks - w.solved_tasks);
|
||||||
|
return `<a class="dh-weak-row" href="/exam-prep/${examKey}/topics/${encodeURIComponent(w.slug)}">
|
||||||
|
<div class="dh-weak-info">
|
||||||
|
<div class="dh-weak-title">${escapeHtml(w.title)}</div>
|
||||||
|
<div class="dh-weak-meta">${w.correct}/${w.attempts} попыток · ${tasksLeft} задач не взято</div>
|
||||||
|
</div>
|
||||||
|
<div class="dh-weak-accuracy">${w.accuracy}%</div>
|
||||||
|
<div class="dh-weak-cta">Прокачать <i data-lucide="arrow-right"></i></div>
|
||||||
|
</a>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="ep-cta-row" style="margin-top:14px">
|
||||||
|
<a class="ep-btn ep-btn-primary" href="/exam-prep/${examKey}/practice?strategy=weak">
|
||||||
|
<i data-lucide="target"></i> Тренировать слабые темы
|
||||||
|
</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMocks(items, examKey) {
|
function renderMocks(items, examKey) {
|
||||||
const el = document.getElementById('dh-mocks-list');
|
const el = document.getElementById('dh-mocks-list');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
@@ -13,11 +13,16 @@
|
|||||||
|
|
||||||
// Per-session state
|
// Per-session state
|
||||||
let batch = null; // { strategy, session_id, tasks: [...] }
|
let batch = null; // { strategy, session_id, tasks: [...] }
|
||||||
let strategy = readPersistedStrategy() || 'random';
|
let strategy = readStrategyFromUrl() || readPersistedStrategy() || 'random';
|
||||||
let count = readPersistedCount();
|
let count = readPersistedCount();
|
||||||
let finalized = false;
|
let finalized = false;
|
||||||
const results = new Map(); // taskId -> { isCorrect: 0|1|null, attempts: number }
|
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) ──────────────────────── */
|
/* ── Strategy persistence (local pref) ──────────────────────── */
|
||||||
function readPersistedStrategy() {
|
function readPersistedStrategy() {
|
||||||
try { return localStorage.getItem(`exam_prep_${examKey}_practice_strategy`); } catch { return null; }
|
try { return localStorage.getItem(`exam_prep_${examKey}_practice_strategy`); } catch { return null; }
|
||||||
@@ -52,7 +57,8 @@
|
|||||||
<button class="pr-strategy-btn ${strategy === 'unsolved' ? 'active' : ''}" data-strat="unsolved">
|
<button class="pr-strategy-btn ${strategy === 'unsolved' ? 'active' : ''}" data-strat="unsolved">
|
||||||
<i data-lucide="circle-dashed"></i> Только нерешённые
|
<i data-lucide="circle-dashed"></i> Только нерешённые
|
||||||
</button>
|
</button>
|
||||||
<button class="pr-strategy-btn disabled" disabled title="Появится в F8 после тегирования тем">
|
<button class="pr-strategy-btn ${strategy === 'weak' ? 'active' : ''}" data-strat="weak"
|
||||||
|
title="Топ-3 темы с худшей точностью (нужно ≥3 попыток в теме)">
|
||||||
<i data-lucide="target"></i> Слабые темы
|
<i data-lucide="target"></i> Слабые темы
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,7 +198,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="pr-summary-stat">
|
<div class="pr-summary-stat">
|
||||||
<div class="ep-stat-label">Стратегия</div>
|
<div class="ep-stat-label">Стратегия</div>
|
||||||
<div class="ep-stat-value" style="font-size:1.1rem">${strategy === 'unsolved' ? 'Нерешённые' : 'Случайные'}</div>
|
<div class="ep-stat-value" style="font-size:1.1rem">${strategyLabel(strategy)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ep-cta-row">
|
<div class="ep-cta-row">
|
||||||
@@ -233,4 +239,11 @@
|
|||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
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 'Случайные';
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user