Files
homeschool/public/js/spaced-repetition.js
T

392 lines
12 KiB
JavaScript

/**
* Spaced Repetition System for Spelling Practice App
*
* Based on the SM-2 algorithm (SuperMemo) with adaptations for spelling practice
* This system schedules words for review based on user performance
* Words are assigned difficulty levels and review intervals are adjusted accordingly
*/
// Module variables
let spacedRepetitionData = null;
let srCurrentUser = null; // Renamed to avoid conflicts
let currentWords = [];
let reviewQueue = [];
// Initialize the spaced repetition system
document.addEventListener('DOMContentLoaded', function() {
// Event listener will be triggered when a user logs in
document.addEventListener('userLoggedIn', function(e) {
srCurrentUser = e.detail.username;
initializeSpacedRepetition(srCurrentUser);
});
});
// Main initialization function
async function initializeSpacedRepetition(username) {
try {
await fetchSpacedRepetitionData(username);
updateSpacedRepetitionUI();
console.log('🔄 Spaced repetition system initialized');
} catch (error) {
console.error('Error initializing spaced repetition:', error);
}
}
// Fetch spaced repetition data for the current user
async function fetchSpacedRepetitionData(username) {
try {
const response = await fetch(`/getSpacedRepetitionData?username=${encodeURIComponent(username)}`);
if (!response.ok) {
throw new Error(`Failed to fetch spaced repetition data: ${response.status}`);
}
spacedRepetitionData = await response.json();
// If there are words due for review today, add them to the review queue
buildReviewQueue();
return spacedRepetitionData;
} catch (error) {
console.error('Error fetching spaced repetition data:', error);
return null;
}
}
// Build a queue of words that are due for review
function buildReviewQueue() {
if (!spacedRepetitionData || !spacedRepetitionData.words) {
reviewQueue = [];
return;
}
const today = new Date().toISOString().split('T')[0];
reviewQueue = Object.entries(spacedRepetitionData.words)
.filter(([_, wordData]) => {
return wordData.nextReview && wordData.nextReview <= today;
})
.map(([word, wordData]) => ({
word,
easeFactor: wordData.easeFactor,
interval: wordData.interval,
repetitions: wordData.repetitions,
lastReview: wordData.lastReview,
nextReview: wordData.nextReview,
priority: calculatePriority(wordData)
}))
.sort((a, b) => b.priority - a.priority); // Sort by priority (highest first)
}
// Calculate priority for a word based on how overdue it is and its difficulty
function calculatePriority(wordData) {
if (!wordData.nextReview) return 0;
const today = new Date();
const nextReview = new Date(wordData.nextReview);
const daysOverdue = Math.max(0, (today - nextReview) / (1000 * 60 * 60 * 24));
// Priority increases with days overdue and decreases with ease factor
// This prioritizes difficult, overdue words
return daysOverdue * (5 - wordData.easeFactor);
}
// Update the UI to show spaced repetition information
function updateSpacedRepetitionUI() {
const srStatsContainer = document.getElementById('srStatsContainer');
if (!srStatsContainer || !spacedRepetitionData) return;
// Clear previous content
srStatsContainer.innerHTML = '';
// Create stats container
const statsDiv = document.createElement('div');
statsDiv.className = 'sr-stats';
const todayCount = reviewQueue.length;
const masteredCount = Object.values(spacedRepetitionData.words || {})
.filter(word => word.easeFactor >= 2.5 && word.repetitions >= 5).length;
const totalCount = Object.keys(spacedRepetitionData.words || {}).length;
statsDiv.innerHTML = `
<div class="sr-stat-card">
<div class="sr-stat-value">${todayCount}</div>
<div class="sr-stat-label">Due Today</div>
</div>
<div class="sr-stat-card">
<div class="sr-stat-value">${masteredCount}</div>
<div class="sr-stat-label">Mastered</div>
</div>
<div class="sr-stat-card">
<div class="sr-stat-value">${totalCount}</div>
<div class="sr-stat-label">Total Words</div>
</div>
`;
srStatsContainer.appendChild(statsDiv);
// Update review button state
const reviewButton = document.getElementById('startSpacedReviewBtn');
if (reviewButton) {
if (todayCount > 0) {
reviewButton.classList.remove('disabled');
reviewButton.innerHTML = `🔄 Start Review (${todayCount})`;
} else {
reviewButton.classList.add('disabled');
reviewButton.innerHTML = '✓ All Caught Up';
}
}
// Update progress visualization
updateProgressVisualization();
}
// Update the progress visualization
function updateProgressVisualization() {
const progressContainer = document.getElementById('srProgressContainer');
if (!progressContainer || !spacedRepetitionData || !spacedRepetitionData.words) return;
// Clear previous content
progressContainer.innerHTML = '';
const words = Object.entries(spacedRepetitionData.words);
if (words.length === 0) {
progressContainer.innerHTML = '<p>No words in your spaced repetition system yet.</p>';
return;
}
// Create progress visualization
const progressDiv = document.createElement('div');
progressDiv.className = 'sr-progress-visualization';
// Group words by mastery level
const levels = {
'New': words.filter(([_, w]) => w.repetitions === 0).length,
'Learning': words.filter(([_, w]) => w.repetitions > 0 && w.repetitions < 3).length,
'Reviewing': words.filter(([_, w]) => w.repetitions >= 3 && w.repetitions < 5).length,
'Mastered': words.filter(([_, w]) => w.repetitions >= 5).length
};
const totalWords = Object.values(levels).reduce((sum, count) => sum + count, 0);
// Create progress bar
const progressBar = document.createElement('div');
progressBar.className = 'sr-progress-bar';
const colors = {
'New': '#ff6b6b',
'Learning': '#feca57',
'Reviewing': '#54a0ff',
'Mastered': '#1dd1a1'
};
Object.entries(levels).forEach(([level, count]) => {
if (count === 0) return;
const percentage = (count / totalWords) * 100;
const segment = document.createElement('div');
segment.className = 'sr-progress-segment';
segment.style.width = `${percentage}%`;
segment.style.backgroundColor = colors[level];
segment.title = `${level}: ${count} words (${percentage.toFixed(1)}%)`;
progressBar.appendChild(segment);
});
// Create legend
const legend = document.createElement('div');
legend.className = 'sr-legend';
Object.entries(levels).forEach(([level, count]) => {
const legendItem = document.createElement('div');
legendItem.className = 'sr-legend-item';
legendItem.innerHTML = `
<span class="sr-legend-color" style="background-color: ${colors[level]}"></span>
<span class="sr-legend-label">${level}: ${count}</span>
`;
legend.appendChild(legendItem);
});
progressDiv.appendChild(progressBar);
progressDiv.appendChild(legend);
progressContainer.appendChild(progressDiv);
}
// Start a spaced repetition review session
async function startSpacedReview() {
if (reviewQueue.length === 0) {
showToast('No words to review right now!', 'info');
return;
}
// Hide student panel and show game section
document.getElementById('studentPanel').classList.add('hidden');
document.getElementById('gameSection').classList.remove('hidden');
// Update UI to indicate this is a spaced repetition session
document.querySelector('#gameSection h3').textContent = '🔄 Spaced Repetition Review';
// Prepare words for the session (take up to 10 words from the queue)
currentWords = reviewQueue.slice(0, 10).map(item => item.word);
// Start the game with these words
await startGame(currentWords, true);
}
// Process results from a spaced repetition session
function processSpacedRepetitionResults(results) {
if (!srCurrentUser || !spacedRepetitionData || !results || results.length === 0) return;
const now = new Date();
const today = now.toISOString().split('T')[0];
// Process each word from the results
results.forEach(result => {
const word = result.word;
const correct = result.correct;
// If word doesn't exist in the system yet, initialize it
if (!spacedRepetitionData.words[word]) {
spacedRepetitionData.words[word] = {
easeFactor: 2.5, // Initial ease factor
interval: 0, // Initial interval (0 = first time)
repetitions: 0, // Number of successful reviews
lastReview: null,
nextReview: null
};
}
const wordData = spacedRepetitionData.words[word];
// Apply SM-2 algorithm
if (correct) {
// Update based on correct answer
if (wordData.repetitions === 0) {
// First successful review
wordData.interval = 1;
} else if (wordData.repetitions === 1) {
// Second successful review
wordData.interval = 6;
} else {
// Subsequent successful reviews - increase interval
wordData.interval = Math.round(wordData.interval * wordData.easeFactor);
}
// Increment repetitions counter
wordData.repetitions += 1;
// Adjust ease factor (make it slightly easier if correct)
wordData.easeFactor = Math.max(1.3, wordData.easeFactor + 0.1);
} else {
// Reset on incorrect answer
wordData.repetitions = 0;
wordData.interval = 1;
// Make it harder if incorrect
wordData.easeFactor = Math.max(1.3, wordData.easeFactor - 0.2);
}
// Update review dates
wordData.lastReview = today;
// Calculate next review date
const nextDate = new Date(now);
nextDate.setDate(nextDate.getDate() + wordData.interval);
wordData.nextReview = nextDate.toISOString().split('T')[0];
});
// Save updated data to the server
saveSpacedRepetitionData();
// Update the queue and UI
buildReviewQueue();
updateSpacedRepetitionUI();
}
// Save the updated spaced repetition data to the server
async function saveSpacedRepetitionData() {
if (!srCurrentUser || !spacedRepetitionData) return;
try {
const response = await fetch('/updateSpacedRepetitionData', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: srCurrentUser,
data: spacedRepetitionData
})
});
if (!response.ok) {
throw new Error(`Failed to update spaced repetition data: ${response.status}`);
}
return true;
} catch (error) {
console.error('Error saving spaced repetition data:', error);
return false;
}
}
// Import new words to the spaced repetition system from a word list
async function importWordsToSR() {
if (!srCurrentUser || !spacedRepetitionData) return;
try {
// Get user's word lists
const response = await fetch(`/getWordlistsForUser?username=${encodeURIComponent(srCurrentUser)}`);
if (!response.ok) {
throw new Error(`Failed to fetch wordlists: ${response.status}`);
}
const wordlists = await response.json();
if (!wordlists || !Array.isArray(wordlists) || wordlists.length === 0) {
showToast('No word lists found', 'error');
return;
}
// For simplicity, let's import all words from the user's word list
const allWords = [...new Set(wordlists)]; // Remove duplicates
// Add each word to the spaced repetition system if not already present
let newWordsCount = 0;
allWords.forEach(word => {
if (!spacedRepetitionData.words[word]) {
spacedRepetitionData.words[word] = {
easeFactor: 2.5,
interval: 0,
repetitions: 0,
lastReview: null,
nextReview: new Date().toISOString().split('T')[0] // Due immediately
};
newWordsCount++;
}
});
// Save the updated data
await saveSpacedRepetitionData();
// Update UI
buildReviewQueue();
updateSpacedRepetitionUI();
showToast(`Added ${newWordsCount} new words to your spaced repetition system`, 'success');
} catch (error) {
console.error('Error importing words:', error);
showToast('Failed to import words', 'error');
}
}
// Export functions to window for use in HTML
window.startSpacedReview = startSpacedReview;
window.importWordsToSR = importWordsToSR;
// Register a handler for when game results are available
document.addEventListener('gameResultsAvailable', function(e) {
// Only process if this was a spaced repetition session
if (e.detail.isSpacedRepetition) {
processSpacedRepetitionResults(e.detail.results);
}
});