feat(flashcards): learning-steps SR — повторный показ «Снова» в сессии, лимит новых карт/день

Tier-1 апгрейд интервального повторения:
- schedule() с состояниями learning/relearning/review вместо плоского sm2():
  новая карта проходит шаги [1,10] мин, «Снова» возвращает на шаг 0 (минуты),
  «Знаю» продвигает шаг → выпуск (1д), «Легко» выпускает сразу (4д); зрелая
  «Снова» = lapse → relearning (ef−0.2, ×0.5).
- study-сессия: динамическая очередь — недоученная карта (graduated=false)
  возвращается через 3 карты и показывается снова в той же сессии.
- лимит новых карт/день (decks.new_per_day, деф.20) в getStudySession и бейдже.
- превью кнопок fcPreview() показывает минуты/дни, зеркало серверной логики.
- миграция 074: state/learning_step/lapses/created_at + new_per_day + индексы.
- тесты SRS 9/9 (шаги, lapse, лимит новых).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:10:00 +03:00
parent cbb6edf372
commit 5c01a5c7ed
4 changed files with 389 additions and 85 deletions
+73 -28
View File
@@ -1435,9 +1435,15 @@ function fxInsert() {
closeModal('modal-formula');
}
/* ════ Study mode ════ */
/* ════ Study mode ════
_studyCards — ДИНАМИЧЕСКАЯ очередь, не фиксированный список: карта, отвеченная
«Снова»/недоученная (server: graduated=false), возвращается в очередь через
FC_RQ_GAP карт и показывается снова в этой же сессии. _studyDone — сколько карт
реально выпущено (ушли из очереди). */
const FC_RQ_GAP = 3;
let _studyCards = [];
let _studyIdx = 0;
let _studyDone = 0;
let _studyFlipped = false;
let _sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
@@ -1456,6 +1462,7 @@ async function startStudyForDeck(deckId) {
}
_studyCards = data.cards;
_studyIdx = 0;
_studyDone = 0;
_studyFlipped = false;
_sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
document.getElementById('study-deck-title').textContent = _curDeck.title;
@@ -1513,10 +1520,11 @@ function setStudyImg(id, url) {
}
function updateStudyProgress() {
const total = _studyCards.length;
const done = _studyIdx;
document.getElementById('study-prog').style.width = (done / total * 100) + '%';
document.getElementById('study-counter').textContent = `${done + 1} / ${total}`;
const remaining = _studyCards.length - _studyIdx; // ещё в очереди (вкл. текущую)
const total = _studyDone + remaining; // растёт при re-queue недоученных
const pct = total ? (_studyDone / total * 100) : 0;
document.getElementById('study-prog').style.width = pct + '%';
document.getElementById('study-counter').textContent = `${Math.min(_studyDone + 1, total)} / ${total}`;
}
function flipCard() {
@@ -1535,15 +1543,35 @@ async function answer(quality) {
else if (quality === 3) _sessionStats.hard++;
else if (quality === 4) _sessionStats.good++;
else if (quality === 5) _sessionStats.easy++;
// send review
await LS.api(`/api/flashcards/cards/${card.id}/review`, {
method: 'POST', body: JSON.stringify({ quality })
}).catch(()=>{});
// send review — ответ несёт следующее расписание и флаг graduated
let resp = null;
try {
resp = await LS.api(`/api/flashcards/cards/${card.id}/review`, {
method: 'POST', body: JSON.stringify({ quality })
});
} catch (e) { /* офлайн — оценим re-queue эвристикой ниже */ }
// обновить локальное расписание карты, чтобы повторное превью было верным
if (resp && resp.next) {
card.state = resp.next.state;
card.learning_step = resp.next.learning_step;
card.ease_factor = resp.next.ease_factor;
card.interval_days = resp.next.interval_days;
card.repetitions = resp.next.repetitions;
card.seen = 1;
}
// карта не выпущена (всё ещё learning/relearning) → вернуть в очередь этой сессии
const requeue = resp ? !resp.graduated : (quality < 3);
// animate swipe
const el = document.getElementById('study-card');
el.classList.add(quality >= 3 ? 'swipe-right' : 'swipe-left');
setTimeout(() => {
_studyIdx++;
_studyCards.splice(_studyIdx, 1); // вынуть текущую
if (requeue) {
const pos = Math.min(_studyIdx + FC_RQ_GAP, _studyCards.length);
_studyCards.splice(pos, 0, card); // показать снова позже
} else {
_studyDone++;
}
if (_studyIdx >= _studyCards.length) finishStudy();
else showStudyCard();
}, 380);
@@ -1570,31 +1598,48 @@ function finishStudy() {
}
/* ── превью следующего интервала для кнопок Снова/Трудно/Знаю/Легко ──
ВАЖНО: точная копия логики интервалов серверного sm2() (flashcardController.js),
иначе превью врёт. Anki-стиль: на новой карте Легко=4д выделяется, на зрелых
Трудно ×1.2 / Знаю ×ef / Легко ×ef×1.3. */
const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3;
function fcNextInterval(card, q) {
const ef = card.ease_factor || 2.5;
const iv = card.interval_days || 1;
const rep = card.repetitions || 0;
if (q < 3) return 1;
if (rep === 0) return q === 5 ? 4 : 1;
if (rep === 1) return q === 3 ? 3 : q === 4 ? 6 : Math.round(6 * FC_EASY_BONUS);
if (q === 3) return Math.max(iv + 1, Math.round(iv * FC_HARD_MULT));
if (q === 4) return Math.round(iv * ef);
return Math.round(iv * ef * FC_EASY_BONUS);
ВАЖНО: зеркало интервальной части серверного schedule() (flashcardController.js),
иначе превью врёт. learning/relearning → минуты (шаги), review → дни SM-2.
Константы держим в синхроне с контроллером. */
const FC_LEARN_STEPS = [1, 10], FC_RELEARN_STEPS = [10];
const FC_GRAD_IV = 1, FC_EASY_IV = 4, FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3;
/* → { kind: 'min'|'day', n } */
function fcPreview(card, q) {
const state = card.state || (card.seen ? 'review' : 'new');
const step = card.learning_step || 0;
const ef = card.ease_factor || 2.5;
const iv = card.interval_days || 0;
const learning = (state === 'new' || state === 'learning' || state === 'relearning');
if (learning) {
const steps = (state === 'relearning') ? FC_RELEARN_STEPS : FC_LEARN_STEPS;
if (q === 5) return { kind: 'day', n: FC_EASY_IV };
if (q < 3) return { kind: 'min', n: steps[0] };
if (q === 3) return { kind: 'min', n: steps[Math.min(step, steps.length - 1)] };
const ns = step + 1; // q === 4 (Знаю)
if (ns >= steps.length)
return { kind: 'day', n: (state === 'relearning') ? Math.max(1, Math.round(iv)) : FC_GRAD_IV };
return { kind: 'min', n: steps[ns] };
}
if (q < 3) return { kind: 'min', n: FC_RELEARN_STEPS[0] };
if (q === 3) return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * FC_HARD_MULT)) };
if (q === 4) return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * ef)) };
return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * ef * FC_EASY_BONUS)) };
}
function fcDaysLabel(n) {
if (n <= 1) return '1 день';
if (n < 5) return n + ' дня';
return n + ' дн.';
}
function fcSchedLabel(p) {
if (p.kind === 'min') return p.n < 60 ? p.n + ' мин' : Math.round(p.n / 60) + ' ч';
return fcDaysLabel(p.n);
}
function updateSQDays(card) {
document.getElementById('sq-days-0').textContent = fcDaysLabel(fcNextInterval(card, 0));
document.getElementById('sq-days-3').textContent = fcDaysLabel(fcNextInterval(card, 3));
document.getElementById('sq-days-4').textContent = fcDaysLabel(fcNextInterval(card, 4));
document.getElementById('sq-days-5').textContent = fcDaysLabel(fcNextInterval(card, 5));
document.getElementById('sq-days-0').textContent = fcSchedLabel(fcPreview(card, 0));
document.getElementById('sq-days-3').textContent = fcSchedLabel(fcPreview(card, 3));
document.getElementById('sq-days-4').textContent = fcSchedLabel(fcPreview(card, 4));
document.getElementById('sq-days-5').textContent = fcSchedLabel(fcPreview(card, 5));
}
/* ── touch/mouse swipe ── */