feat(trainer): P2 — умная тренировка, интервальное повторение, итог сессии

- adaptive.js (TrainerAdaptive): nextSkill (in-session повтор → серверный due → прогрессия → удержание), onWrong/onCorrect (очередь повторения), sessionStats
- умная тренировка на странице (тумблер, по умолч. вкл): авто-подбор навыка от простого к сложному, возврат ошибок
- сессия из 10 задач + экран «Итог сессии» (верно/точность/навыки/стоит повторить); неверный ответ авто-показывает решение
- сервер: SR-поля box+due_at на practice_progress (мигр.082, Leitner 0/1/3/7/16/30 дн), listProgress отдаёт box/due_at/due
- смоуки: adaptive 12/12, страница 23/23, practice.test.js 11/11 (+SR box/due); план P2 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 13:46:29 +03:00
parent 20b8ce2c5b
commit 48a73d9f8e
6 changed files with 281 additions and 29 deletions
+25 -11
View File
@@ -14,12 +14,17 @@ const db = require('../db/db');
const MAX_SKILL = 120; // длина skill (TEXT)
const MASTERY_STREAK = 5; // серия верных подряд для «освоено»
// Интервалы повторения (дни) по уровню Leitner-коробки box 0..5.
const INTERVAL_DAYS = [0, 1, 3, 7, 16, 30];
/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам. */
/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам.
* `due` (0/1) — навык пора повторить (срок прошёл или не назначен). */
function listProgress(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
updated_at
FROM practice_progress
WHERE user_id = ?
ORDER BY updated_at DESC, id DESC
@@ -41,15 +46,20 @@ function submitAttempt(req, res) {
const correct = b.correct;
const existing = db.prepare(
'SELECT id, solved, attempts, cur_streak, best_streak, mastered FROM practice_progress WHERE user_id = ? AND skill = ?'
'SELECT id, solved, attempts, cur_streak, best_streak, mastered, box FROM practice_progress WHERE user_id = ? AND skill = ?'
).get(uid, skill);
// Leitner: верно → box+1 (до 5), неверно → 0. Срок = сейчас + интервал(box).
const prevBox = existing ? (existing.box || 0) : 0;
const box = correct ? Math.min(prevBox + 1, 5) : 0;
const dueMod = '+' + INTERVAL_DAYS[box] + ' days';
if (!existing) {
const curStreak = correct ? 1 : 0;
db.prepare(`
INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?, datetime('now'))
`).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0);
INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, datetime('now', ?), datetime('now'))
`).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0, box, dueMod);
} else {
const curStreak = correct ? (existing.cur_streak + 1) : 0;
const bestStreak = Math.max(existing.best_streak || 0, curStreak);
@@ -57,14 +67,18 @@ function submitAttempt(req, res) {
db.prepare(`
UPDATE practice_progress
SET solved = solved + ?, attempts = attempts + 1,
cur_streak = ?, best_streak = ?, mastered = ?, updated_at = datetime('now')
cur_streak = ?, best_streak = ?, mastered = ?, box = ?, due_at = datetime('now', ?),
updated_at = datetime('now')
WHERE id = ?
`).run(correct ? 1 : 0, curStreak, bestStreak, mastered, existing.id);
`).run(correct ? 1 : 0, curStreak, bestStreak, mastered, box, dueMod, existing.id);
}
const row = db.prepare(
'SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at FROM practice_progress WHERE user_id = ? AND skill = ?'
).get(uid, skill);
const row = db.prepare(`
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
updated_at
FROM practice_progress WHERE user_id = ? AND skill = ?
`).get(uid, skill);
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
}
@@ -0,0 +1,13 @@
-- ═══════════════════════════════════════════════════════════════
-- 082: SR-поля тренажёра (интервальное повторение по навыкам, Фаза 2).
--
-- К practice_progress добавляем Leitner-«коробку» и срок следующего показа:
-- box — уровень 0..5 (выше = увереннее освоено, реже повторяем).
-- due_at — когда навык снова стоит показать (datetime). NULL = «как можно скорее».
-- На верный ответ box растёт и срок отодвигается; на ошибку box сбрасывается в 0
-- и срок = сейчас (навык всплывёт первым при следующем заходе). Адаптивный
-- подборщик на клиенте показывает «просроченные» навыки (due_at <= now) раньше.
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE practice_progress ADD COLUMN box INTEGER NOT NULL DEFAULT 0;
ALTER TABLE practice_progress ADD COLUMN due_at TEXT;