diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index 4c3d39c..604fa5b 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -10,52 +10,116 @@ function safeImg(url) { return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : ''; } -/* ── SM-2 (Anki-стиль: кнопки различаются) ───────────────────────────────── +/* ── Планировщик с learning-steps (Anki-стиль) ───────────────────────────── quality: 0/2 = Снова, 3 = Трудно, 4 = Знаю, 5 = Легко. - В отличие от чистого SM-2, интервал зависит от оценки уже на первых повторах: - на новой карте Снова/Трудно/Знаю → 1д, Легко → 4д; на зрелых — Трудно ×1.2, - Знаю ×ef, Легко ×ef×1.3 (easy-бонус). ВАЖНО: клиентское превью - fcNextInterval() в flashcards.html — точная копия этой логики интервалов. - ─────────────────────────────────────────────────────────────────────── */ -const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3; -function sm2(easeFactor, intervalDays, repetitions, quality) { - let ef = easeFactor; - let n = repetitions; - let iv = intervalDays; - if (quality < 3) { - n = 0; - iv = 1; // Снова — сброс - } else { - if (n === 0) { - iv = (quality === 5) ? 4 : 1; // выпуск: Легко 4д, иначе 1д - } else if (n === 1) { - iv = (quality === 3) ? 3 : (quality === 4) ? 6 : Math.round(6 * FC_EASY_BONUS); + Карточка живёт в одном из состояний: + learning — новая, проходит шаги обучения FC_LEARN_STEPS (минуты); + relearning — зрелая, провалена → снова шаги FC_RELEARN_STEPS; + review — выпущена, день-интервалы SM-2. + + На learning/relearning «Снова» возвращает на шаг 0 (минуты!) и карта + ПОВТОРНО показывается в той же сессии (re-queue делает клиент по флагу + graduated=false). «Знаю» продвигает шаг; пройдя последний — выпуск в review + (FC_GRAD_IV дн.), «Легко» выпускает сразу (FC_EASY_IV дн.). На review «Снова» + = lapse → relearning (ef −0.2, интервал ×0.5). Зрелые интервалы: Трудно ×1.2, + Знаю ×ef, Легко ×ef×1.3. + + Под-дневные интервалы хранятся в due_at, поэтому interval_days — целые дни + последнего выпуска. ВАЖНО: клиентское превью fcPreview() в flashcards.html — + зеркало интервальной части этой логики. + ─────────────────────────────────────────────────────────────────────── */ +const FC_LEARN_STEPS = [1, 10]; // минуты — шаги новой карты +const FC_RELEARN_STEPS = [10]; // минуты — шаги после провала зрелой +const FC_GRAD_IV = 1; // дней — выпуск через «Знаю» +const FC_EASY_IV = 4; // дней — выпуск через «Легко» +const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3, FC_MIN_EF = 1.3; + +/* prev: { state, learning_step, ease_factor, interval_days, repetitions, lapses }. + Возвращает следующее расписание + dueInSec (для клиентского re-queue) и + graduated (карта в review → из сессии можно убрать). */ +function schedule(prev, quality, nowMs) { + let state = prev.state || 'new'; + let step = prev.learning_step || 0; + let ef = prev.ease_factor || 2.5; + let iv = prev.interval_days || 0; + let reps = prev.repetitions || 0; + let lapses = prev.lapses || 0; + let dueSec; + + const learning = (state === 'new' || state === 'learning' || state === 'relearning'); + + if (learning) { + const steps = (state === 'relearning') ? FC_RELEARN_STEPS : FC_LEARN_STEPS; + if (quality === 5) { // Легко → выпуск сразу + state = 'review'; step = 0; reps = Math.max(reps, 1); + iv = FC_EASY_IV; dueSec = iv * 86400; + } else if (quality < 3) { // Снова → первый шаг (минуты) + if (state === 'new') state = 'learning'; + step = 0; dueSec = steps[0] * 60; + } else if (quality === 3) { // Трудно → повтор текущего шага + if (state === 'new') state = 'learning'; + dueSec = steps[Math.min(step, steps.length - 1)] * 60; + } else { // Знаю → следующий шаг + if (state === 'new') state = 'learning'; + step += 1; + if (step >= steps.length) { // прошёл все шаги → выпуск + const grad = (state === 'relearning') ? Math.max(1, Math.round(iv)) : FC_GRAD_IV; + state = 'review'; step = 0; reps = Math.max(reps, 1); + iv = grad; dueSec = iv * 86400; + } else { + dueSec = steps[step] * 60; + } + } + ef = Math.max(FC_MIN_EF, ef); // ease меняем только на review-ответах + } else { // state === 'review' + if (quality < 3) { // провал → relearning + lapses += 1; + ef = Math.max(FC_MIN_EF, ef - 0.20); + iv = Math.max(1, Math.round(iv * 0.5)); // целевой интервал после переучивания + reps = 0; state = 'relearning'; step = 0; + dueSec = FC_RELEARN_STEPS[0] * 60; } else { if (quality === 3) iv = Math.max(iv + 1, Math.round(iv * FC_HARD_MULT)); - else if (quality === 4) iv = Math.round(iv * ef); - else iv = Math.round(iv * ef * FC_EASY_BONUS); + else if (quality === 4) iv = Math.max(iv + 1, Math.round(iv * ef)); + else iv = Math.max(iv + 1, Math.round(iv * ef * FC_EASY_BONUS)); + reps += 1; + ef = Math.max(FC_MIN_EF, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)); + dueSec = iv * 86400; } - n++; } - ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)); - const due = new Date(Date.now() + iv * 86400000).toISOString(); - return { easeFactor: ef, intervalDays: iv, repetitions: n, dueAt: due }; + + const dueAt = new Date(nowMs + dueSec * 1000).toISOString(); + return { state, learningStep: step, easeFactor: ef, intervalDays: iv, repetitions: reps, + lapses, dueAt, dueInSec: dueSec, graduated: state === 'review' }; } /* ── GET /api/flashcards/decks ─────────────────────────────────────────── */ function listDecks(req, res) { const uid = req.user.id; + // due_count = зрелые/learning к повтору (due_at<=now) + новые, но не больше + // дневного лимита new_per_day за вычетом уже введённых сегодня — чтобы бейдж + // отражал реальный размер сессии, а не весь бэклог новых карт. const decks = db.prepare(` SELECT d.*, (SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count, - (SELECT COUNT(*) FROM flashcard_cards c - LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? - WHERE c.deck_id = d.id AND (r.id IS NULL OR r.due_at <= datetime('now'))) AS due_count + ( + (SELECT COUNT(*) FROM flashcard_cards c + JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? + WHERE c.deck_id = d.id AND r.due_at <= datetime('now')) + + MIN( + (SELECT COUNT(*) FROM flashcard_cards c + LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? + WHERE c.deck_id = d.id AND r.id IS NULL), + MAX(0, d.new_per_day - (SELECT COUNT(*) FROM flashcard_reviews r + JOIN flashcard_cards c ON c.id = r.card_id + WHERE c.deck_id = d.id AND r.user_id = ? AND date(r.created_at) = date('now'))) + ) + ) AS due_count FROM flashcard_decks d WHERE d.user_id = ? ORDER BY d.created_at DESC - `).all(uid, uid); + `).all(uid, uid, uid, uid); res.json({ decks }); } @@ -209,31 +273,45 @@ function deleteCard(req, res) { /* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */ function getStudySession(req, res) { const uid = req.user.id; - const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) + const deck = db.prepare(`SELECT id, new_per_day FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); - // due cards first, then new cards (no review yet), limit 20 - const cards = db.prepare(` + + // 1) Карты к повторению (есть строка отзыва, due_at<=now): learning — по минутам, + // review — по дням. Отдаём по возрастанию due_at (срочные learning впереди). + const dueCards = db.prepare(` SELECT c.id, c.front, c.back, c.front_image, c.back_image, - COALESCE(r.ease_factor, 2.5) AS ease_factor, - COALESCE(r.interval_days, 1) AS interval_days, - COALESCE(r.repetitions, 0) AS repetitions, - COALESCE(r.due_at, datetime('now')) AS due_at, - r.last_reviewed, - CASE WHEN r.id IS NULL THEN 0 ELSE 1 END AS seen + r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed, + r.state, r.learning_step, 1 AS seen + FROM flashcard_cards c + JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? + WHERE c.deck_id = ? AND r.due_at <= datetime('now') + ORDER BY r.due_at ASC + LIMIT 100 + `).all(uid, deck.id); + + // 2) Новые карты (без отзыва), но не больше дневного лимита за вычетом уже + // введённых сегодня — защита от перегруза на большой колоде. + const newToday = db.prepare(` + SELECT COUNT(*) AS n FROM flashcard_reviews r + JOIN flashcard_cards c ON c.id = r.card_id + WHERE c.deck_id = ? AND r.user_id = ? AND date(r.created_at) = date('now') + `).get(deck.id, uid).n; + const newBudget = Math.max(0, (deck.new_per_day || 20) - newToday); + const newCards = newBudget > 0 ? db.prepare(` + SELECT c.id, c.front, c.back, c.front_image, c.back_image, + 2.5 AS ease_factor, 0 AS interval_days, 0 AS repetitions, + datetime('now') AS due_at, NULL AS last_reviewed, + 'new' AS state, 0 AS learning_step, 0 AS seen FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? - WHERE c.deck_id = ? - AND (r.id IS NULL OR r.due_at <= datetime('now')) - ORDER BY seen ASC, r.due_at ASC - LIMIT 20 - `).all(uid, deck.id); - const total_due = db.prepare(` - SELECT COUNT(*) AS n FROM flashcard_cards c - LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? - WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now')) - `).get(uid, deck.id).n; - res.json({ cards, total_due }); + WHERE c.deck_id = ? AND r.id IS NULL + ORDER BY c.order_idx, c.id + LIMIT ? + `).all(uid, deck.id, newBudget) : []; + + const cards = dueCards.concat(newCards); + res.json({ cards, total_due: cards.length }); } /* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */ @@ -254,22 +332,39 @@ function submitReview(req, res) { `SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?` ).get(uid, card.id); - const prev = existing || { ease_factor: 2.5, interval_days: 1, repetitions: 0 }; - const next = sm2(prev.ease_factor, prev.interval_days, prev.repetitions, quality); + const prev = existing || { state: 'new', learning_step: 0, ease_factor: 2.5, + interval_days: 0, repetitions: 0, lapses: 0 }; + const next = schedule(prev, quality, Date.now()); if (existing) { db.prepare(` UPDATE flashcard_reviews - SET ease_factor=?, interval_days=?, repetitions=?, due_at=?, last_reviewed=datetime('now') + SET state=?, learning_step=?, ease_factor=?, interval_days=?, repetitions=?, + lapses=?, due_at=?, last_reviewed=datetime('now') WHERE user_id=? AND card_id=? - `).run(next.easeFactor, next.intervalDays, next.repetitions, next.dueAt, uid, card.id); + `).run(next.state, next.learningStep, next.easeFactor, next.intervalDays, + next.repetitions, next.lapses, next.dueAt, uid, card.id); } else { db.prepare(` - INSERT INTO flashcard_reviews (user_id, card_id, ease_factor, interval_days, repetitions, due_at, last_reviewed) - VALUES (?,?,?,?,?,?,datetime('now')) - `).run(uid, card.id, next.easeFactor, next.intervalDays, next.repetitions, next.dueAt); + INSERT INTO flashcard_reviews + (user_id, card_id, state, learning_step, ease_factor, interval_days, + repetitions, lapses, due_at, last_reviewed, created_at) + VALUES (?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now')) + `).run(uid, card.id, next.state, next.learningStep, next.easeFactor, + next.intervalDays, next.repetitions, next.lapses, next.dueAt); } - res.json({ ok: true, next_review: next.dueAt, interval_days: next.intervalDays }); + res.json({ + ok: true, + graduated: next.graduated, // true → карта в review (из сессии можно убрать) + due_in_sec: next.dueInSec, + next_review: next.dueAt, + interval_days: next.intervalDays, + next: { + state: next.state, learning_step: next.learningStep, + ease_factor: next.easeFactor, interval_days: next.intervalDays, + repetitions: next.repetitions, + }, + }); } /* ── GET /api/flashcards/stats ─────────────────────────────────────────── */ diff --git a/backend/src/db/migrations/074_flashcard_learning_steps.sql b/backend/src/db/migrations/074_flashcard_learning_steps.sql new file mode 100644 index 0000000..fb344b6 --- /dev/null +++ b/backend/src/db/migrations/074_flashcard_learning_steps.sql @@ -0,0 +1,31 @@ +-- 074_flashcard_learning_steps.sql +-- Tier-1 апгрейд интервального повторения: настоящие learning-steps (под-дневные +-- интервалы), повторный показ «Снова» в той же сессии и лимит новых карт в день. +-- +-- flashcard_reviews получает состояние карточки: +-- state — 'review' (зрелая, день-интервалы SM-2) | 'learning' (новая в шагах) +-- | 'relearning' (зрелая после провала, снова в шагах). +-- Старые строки = 'review' (они уже были выпущены старым алгоритмом). +-- learning_step — индекс текущего шага обучения (0..N). +-- lapses — сколько раз зрелая карта проваливалась (для статистики). +-- created_at — когда карта впервые введена в оборот (для лимита новых/день). +-- Старым строкам ставим '' (date('')=NULL → не считаются «введёнными +-- сегодня»); новые строки контроллер заполняет datetime('now'). +-- ВАЖНО: под-дневные интервалы живут в due_at (TEXT datetime с минутами), поэтому +-- interval_days остаётся INTEGER — менять тип не нужно. +-- +-- flashcard_decks.new_per_day — сколько новых карт колоды показывать за день (деф. 20). +-- +-- Примечание: ALTER TABLE ADD COLUMN в SQLite запрещает выражение-default +-- (datetime('now')), поэтому created_at = '' и проставляется кодом на INSERT. + +ALTER TABLE flashcard_reviews ADD COLUMN state TEXT NOT NULL DEFAULT 'review'; +ALTER TABLE flashcard_reviews ADD COLUMN learning_step INTEGER NOT NULL DEFAULT 0; +ALTER TABLE flashcard_reviews ADD COLUMN lapses INTEGER NOT NULL DEFAULT 0; +ALTER TABLE flashcard_reviews ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; + +ALTER TABLE flashcard_decks ADD COLUMN new_per_day INTEGER NOT NULL DEFAULT 20; + +-- Индексы под горячие пути (getCards / getStudySession / listDecks-подсчёты). +CREATE INDEX IF NOT EXISTS idx_fc_cards_deck ON flashcard_cards(deck_id); +CREATE INDEX IF NOT EXISTS idx_fc_reviews_card ON flashcard_reviews(card_id); diff --git a/backend/tests/flashcards-srs.test.js b/backend/tests/flashcards-srs.test.js new file mode 100644 index 0000000..aec215f --- /dev/null +++ b/backend/tests/flashcards-srs.test.js @@ -0,0 +1,133 @@ +'use strict'; +/** + * Integration tests: интервальное повторение флешкарт (Tier-1 апгрейд). + * Covers: learning-steps (новая карта проходит шаги в минутах), выпуск в review + * через «Знаю»/«Легко», lapse зрелой карты → relearning, флаг graduated для + * клиентского re-queue, и лимит новых карт/день в study-сессии. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, db, inject, getToken, cleanup } = require('./setup'); + +// Маршрут флешкарт setup.js не монтирует (как и custom-sims) — монтируем сами. +app.use('/api/flashcards', require('../src/routes/flashcards')); + +after(() => cleanup()); + +/* helpers */ +async function mkDeck(token, title = 'Колода') { + const r = await inject('POST', '/api/flashcards/decks', { title }, token); + assert.equal(r.status, 200, `deck create: ${r.status} ${JSON.stringify(r.body)}`); + return r.body.id; +} +async function mkCard(token, deckId, front = 'Q', back = 'A') { + const r = await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front, back }, token); + assert.equal(r.status, 200, `card create: ${r.status}`); + return r.body.id; +} +function review(token, cardId, quality) { + return inject('POST', `/api/flashcards/cards/${cardId}/review`, { quality }, token); +} + +describe('flashcards SRS — learning steps & limits', () => { + let token; + before(async () => { token = (await getToken('student')).token; }); + + it('review validates quality range (0..5)', async () => { + const deck = await mkDeck(token); + const card = await mkCard(token, deck); + assert.equal((await review(token, card, 7)).status, 400); + assert.equal((await review(token, card, -1)).status, 400); + }); + + it('new card + «Снова» (q0) → learning, due через 1 минуту, не выпущена', async () => { + const deck = await mkDeck(token); + const card = await mkCard(token, deck); + const r = await review(token, card, 0); + assert.equal(r.status, 200); + assert.equal(r.body.graduated, false, 'не выпущена'); + assert.equal(r.body.due_in_sec, 60, '1 минута'); + assert.equal(r.body.next.state, 'learning'); + assert.equal(r.body.next.learning_step, 0); + }); + + it('new card + «Знаю» (q4) → второй шаг 10 минут, всё ещё learning', async () => { + const deck = await mkDeck(token); + const card = await mkCard(token, deck); + const r = await review(token, card, 4); + assert.equal(r.body.graduated, false); + assert.equal(r.body.due_in_sec, 600, '10 минут'); + assert.equal(r.body.next.state, 'learning'); + assert.equal(r.body.next.learning_step, 1); + }); + + it('two «Знаю» подряд выпускают карту в review (interval 1 день)', async () => { + const deck = await mkDeck(token); + const card = await mkCard(token, deck); + await review(token, card, 4); // шаг 0 → 1 + const r = await review(token, card, 4); // шаг 1 → выпуск + assert.equal(r.body.graduated, true, 'выпущена в review'); + assert.equal(r.body.next.state, 'review'); + assert.equal(r.body.interval_days, 1); + assert.equal(r.body.due_in_sec, 86400); + }); + + it('«Легко» (q5) на новой карте выпускает сразу (interval 4 дня)', async () => { + const deck = await mkDeck(token); + const card = await mkCard(token, deck); + const r = await review(token, card, 5); + assert.equal(r.body.graduated, true); + assert.equal(r.body.next.state, 'review'); + assert.equal(r.body.interval_days, 4); + }); + + it('зрелая карта + «Снова» → lapse: relearning, lapses=1, due 10 минут', async () => { + const deck = await mkDeck(token); + const card = await mkCard(token, deck); + await review(token, card, 5); // сразу в review (iv=4) + const r = await review(token, card, 0); // провал + assert.equal(r.body.graduated, false, 'ушла на переучивание'); + assert.equal(r.body.next.state, 'relearning'); + assert.equal(r.body.due_in_sec, 600, 'relearn-шаг 10 минут'); + // lapses фиксируется в БД + const row = db.prepare('SELECT lapses FROM flashcard_reviews WHERE card_id=?').get(card); + assert.equal(row.lapses, 1); + }); + + it('зрелая карта + «Знаю» растёт по дням (≥ предыдущего интервала)', async () => { + const deck = await mkDeck(token); + const card = await mkCard(token, deck); + await review(token, card, 5); // iv=4, review + const r = await review(token, card, 4); // Знаю на зрелой + assert.equal(r.body.next.state, 'review'); + assert.ok(r.body.interval_days > 4, `интервал вырос: ${r.body.interval_days}`); + }); + + it('study-сессия уважает лимит новых карт/день (new_per_day)', async () => { + const deck = await mkDeck(token, 'Большая'); + db.prepare('UPDATE flashcard_decks SET new_per_day=? WHERE id=?').run(2, deck); + for (let i = 0; i < 5; i++) await mkCard(token, deck, 'Q' + i, 'A' + i); + + const s1 = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token); + assert.equal(s1.status, 200); + assert.equal(s1.body.cards.length, 2, 'не больше лимита новых'); + assert.ok(s1.body.cards.every(c => c.state === 'new'), 'все — новые'); + + // «вводим» обе карты (review) — бюджет на сегодня исчерпан + for (const c of s1.body.cards) await review(token, c.id, 4); + const s2 = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token); + // введённые карты теперь learning (due через минуты, не сейчас) → сессия пуста + assert.equal(s2.body.cards.length, 0, 'бюджет новых исчерпан, learning ещё не due'); + }); + + it('study-сессия возвращает state/learning_step для превью кнопок', async () => { + const deck = await mkDeck(token, 'Превью'); + const card = await mkCard(token, deck); + const s = await inject('GET', `/api/flashcards/decks/${deck}/study`, null, token); + const c = s.body.cards.find(x => x.id === card); + assert.ok(c, 'карта в сессии'); + assert.equal(c.state, 'new'); + assert.equal(c.learning_step, 0); + assert.equal(c.seen, 0); + }); +}); diff --git a/frontend/flashcards.html b/frontend/flashcards.html index 6008c19..b41d1c1 100644 --- a/frontend/flashcards.html +++ b/frontend/flashcards.html @@ -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 ── */