Files
AI_English/src/js/services/ebbinghaus.js
T
2026-07-19 21:09:58 +08:00

149 lines
4.4 KiB
JavaScript

import { EBBINGHAUS_INTERVALS } from '../constants.js';
import { state } from '../core/state.js';
import { MAX_RECORDS, saveRecords, saveSchedule } from '../core/storage.js';
/**
* Ebbinghaus scheduling and study-record helpers. Inputs are word IDs, dates,
* and quiz outcomes; mutations update shared state and persist records:<libraryId>
* and schedule:<libraryId> through the storage service.
*/
// ==================== Ebbinghaus Algorithm ====================
export function toLocalDateStr(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function getToday() {
return toLocalDateStr(new Date());
}
export function addDays(dateStr, days) {
const [y, m, day] = dateStr.split('-').map(Number);
const d = new Date(y, m - 1, day);
d.setDate(d.getDate() + days);
return toLocalDateStr(d);
}
export function getDueWords() {
const today = getToday();
const due = [];
for (const [wordId, sch] of Object.entries(state.schedule)) {
if (sch.nextReview <= today) {
const word = state.words.find(w => w.id === Number(wordId));
if (word) due.push({ ...word, schedule: sch });
}
}
return due;
}
export function initWordSchedule(wordId, save = true) {
if (state.schedule[wordId]) return true;
state.schedule[wordId] = {
stage: 0,
nextReview: getToday(),
correctCount: 0,
incorrectCount: 0
};
if (!save || saveSchedule()) return true;
delete state.schedule[wordId];
return false;
}
export function trimRecords(records, maxRecords, keepPerWord) {
if (records.length <= maxRecords) return records;
const selected = new Set();
const counts = new Map();
// 第一轮尽可能为每个单词和记录类型保留最新一条,避免整个组合被裁掉
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i];
const key = `${record.wordId}:${record.type}`;
if (counts.has(key)) continue;
selected.add(i);
counts.set(key, 1);
if (selected.size === maxRecords) break;
}
// 第二轮按时间从新到旧填充剩余容量,每个组合优先保留 keepPerWord 条
for (let i = records.length - 1; i >= 0 && selected.size < maxRecords; i--) {
if (selected.has(i)) continue;
const record = records[i];
const key = `${record.wordId}:${record.type}`;
const count = counts.get(key) || 0;
if (count < keepPerWord) {
selected.add(i);
counts.set(key, count + 1);
}
}
// 组合较少时前两轮可能未填满容量,继续保留其余最新记录,避免一次裁剪丢失大量历史
for (let i = records.length - 1; i >= 0 && selected.size < maxRecords; i--) {
selected.add(i);
}
return [...selected]
.sort((a, b) => a - b)
.map(index => records[index]);
}
export function recordAndUpdateSchedule(wordId, isCorrect, type, quizMode, options = {}) {
const previousRecords = state.records;
const previousSchedule = state.schedule;
const nextRecords = previousRecords.slice();
const previousWordSchedule = previousSchedule[wordId];
const nextWordSchedule = previousWordSchedule ? { ...previousWordSchedule } : {
stage: 0,
nextReview: getToday(),
correctCount: 0,
incorrectCount: 0
};
nextRecords.push({
wordId,
date: getToday(),
time: Date.now(),
isCorrect,
type,
quizMode
});
let recordsToSave = nextRecords;
if (options.removeIncorrectQuizRecords && isCorrect) {
recordsToSave = nextRecords.filter(record => !(
record.wordId === wordId
&& record.type === 'quiz'
&& !record.isCorrect
));
}
if (recordsToSave.length > MAX_RECORDS) {
recordsToSave = trimRecords(recordsToSave, MAX_RECORDS, 10);
}
if (isCorrect) {
nextWordSchedule.correctCount++;
nextWordSchedule.stage = Math.min(nextWordSchedule.stage + 1, EBBINGHAUS_INTERVALS.length - 1);
} else {
nextWordSchedule.incorrectCount++;
nextWordSchedule.stage = Math.max(0, nextWordSchedule.stage - 1);
}
nextWordSchedule.nextReview = addDays(getToday(), EBBINGHAUS_INTERVALS[nextWordSchedule.stage]);
state.records = recordsToSave;
state.schedule = { ...previousSchedule, [wordId]: nextWordSchedule };
const recordsSaved = saveRecords();
const scheduleSaved = saveSchedule();
if (recordsSaved && scheduleSaved) return true;
state.records = previousRecords;
state.schedule = previousSchedule;
saveRecords();
saveSchedule();
return false;
}