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:
+73
-28
@@ -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 ── */
|
||||
|
||||
Reference in New Issue
Block a user