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:
@@ -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 ─────────────────────────────────────────── */
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+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