149 lines
4.4 KiB
JavaScript
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;
|
|
}
|
|
|