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: * and schedule: 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; }