Implement version 3.0.0 with Analytics Dashboard, Gamification Expansion, and Spaced Repetition System
This commit is contained in:
1 parent
27af564a41
commit
7d8470df84
20 files changed
+22999
-23
No files matched your search
+56
-18
@@ -3,6 +3,7 @@ let words = [];
|
||||
let currentWord = '';
|
||||
let currentIndex = 0;
|
||||
let results = [];
|
||||
let isSpacedRepetitionMode = false;
|
||||
// 📝 Check user's answer and move to next
|
||||
window.submitAnswer = function() {
|
||||
const input = document.getElementById('userInput').value.trim();
|
||||
@@ -73,7 +74,7 @@ function showStudent() {
|
||||
}
|
||||
|
||||
// 🚀 Begin game session
|
||||
window.startGame = async function() {
|
||||
window.startGame = async function(customWords = null, isSpacedRepetition = false) {
|
||||
console.log('🎮 Starting spelling game');
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
||||
@@ -95,29 +96,43 @@ window.startGame = async function() {
|
||||
document.getElementById('summary')?.classList.add('hidden');
|
||||
document.getElementById('badgeDisplay')?.classList.add('hidden');
|
||||
|
||||
// Fetch words
|
||||
const response = await fetch(`/getWordList?username=${user.username}`);
|
||||
const data = await response.json();
|
||||
console.log('Received words:', data);
|
||||
|
||||
let weekWords = [];
|
||||
// If weeks format and activeWeek is set, use only that week's words
|
||||
if (data.words && Array.isArray(data.words)) {
|
||||
weekWords = data.words;
|
||||
} else if (data.words && data.words.weeks && data.words.activeWeek) {
|
||||
const active = data.words.activeWeek;
|
||||
const found = data.words.weeks.find(w => w.date === active);
|
||||
if (found) weekWords = found.words;
|
||||
let wordList;
|
||||
|
||||
// Use custom words if provided (for spaced repetition)
|
||||
if (customWords && Array.isArray(customWords) && customWords.length > 0) {
|
||||
wordList = customWords;
|
||||
isSpacedRepetitionMode = isSpacedRepetition;
|
||||
console.log('Using custom word list for spaced repetition:', wordList);
|
||||
} else {
|
||||
// Fetch words from user's word list
|
||||
const response = await fetch(`/getWordList?username=${user.username}`);
|
||||
const data = await response.json();
|
||||
console.log('Received words:', data);
|
||||
|
||||
let weekWords = [];
|
||||
// If weeks format and activeWeek is set, use only that week's words
|
||||
if (data.words && Array.isArray(data.words)) {
|
||||
weekWords = data.words;
|
||||
} else if (data.words && data.words.weeks && data.words.activeWeek) {
|
||||
const active = data.words.activeWeek;
|
||||
const found = data.words.weeks.find(w => w.date === active);
|
||||
if (found) weekWords = found.words;
|
||||
} else if (Array.isArray(data)) {
|
||||
// If data is already an array
|
||||
weekWords = data;
|
||||
}
|
||||
|
||||
wordList = weekWords;
|
||||
}
|
||||
|
||||
if (!weekWords.length) {
|
||||
if (!wordList.length) {
|
||||
alert('No words found for practice');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start game
|
||||
words = [...weekWords];
|
||||
typingWords = [...weekWords]; // Initialize typing words too
|
||||
words = [...wordList];
|
||||
typingWords = [...wordList]; // Initialize typing words too
|
||||
currentIndex = 0;
|
||||
results = []; // Reset results array
|
||||
showWord();
|
||||
@@ -176,7 +191,8 @@ function showSummary() {
|
||||
const summary = document.getElementById('summary');
|
||||
let correct = 0;
|
||||
let missed = [];
|
||||
|
||||
|
||||
// Process results to calculate correct answers and build a report
|
||||
const report = results.map(({ word, attempt }) => {
|
||||
const isCorrect = word.toLowerCase() === attempt.toLowerCase();
|
||||
if (isCorrect) correct++;
|
||||
@@ -184,11 +200,33 @@ function showSummary() {
|
||||
return `<li>${word} — <strong style="color:${isCorrect ? 'green' : 'red'}">${attempt || '(no answer)'}</strong></li>`;
|
||||
}).join('');
|
||||
|
||||
// Create detailed results for spaced repetition processing
|
||||
const detailedResults = results.map(({ word, attempt }) => {
|
||||
return {
|
||||
word,
|
||||
correct: word.toLowerCase() === attempt.toLowerCase(),
|
||||
attempt
|
||||
};
|
||||
});
|
||||
|
||||
// Dispatch event for spaced repetition system if in SR mode
|
||||
if (isSpacedRepetitionMode) {
|
||||
const event = new CustomEvent('gameResultsAvailable', {
|
||||
detail: {
|
||||
isSpacedRepetition: true,
|
||||
results: detailedResults
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Build and display summary
|
||||
summary.innerHTML = `
|
||||
<h2>Results</h2>
|
||||
<p>You got ${correct} out of ${words.length} correct (${Math.round((correct / words.length) * 100)}%)</p>
|
||||
<ul>${report}</ul>
|
||||
${missed.length ? `<button onclick='retryMissed(${JSON.stringify(missed)})'>Retry Missed Words</button>` : ''}
|
||||
${isSpacedRepetitionMode ? `<button onclick='backToStudentDashboard()' class="btn-primary">Back to Dashboard</button>` : ''}
|
||||
`;
|
||||
|
||||
summary.classList.remove('hidden');
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Gamification features for Spelling Practice App
|
||||
* - Leaderboards
|
||||
* - Challenges & Achievements
|
||||
* - Streaks
|
||||
* - Points system
|
||||
*/
|
||||
|
||||
// Global variables for gamification
|
||||
let userProgress = null;
|
||||
let challenges = null;
|
||||
let leaderboards = null;
|
||||
let currentUser = null;
|
||||
|
||||
// Initialize gamification features
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Event listener will be triggered when a user logs in
|
||||
document.addEventListener('userLoggedIn', function(e) {
|
||||
currentUser = e.detail.username;
|
||||
initGamification(currentUser);
|
||||
});
|
||||
});
|
||||
|
||||
// Main initialization function for gamification
|
||||
async function initGamification(username) {
|
||||
try {
|
||||
// Fetch all required data
|
||||
await Promise.all([
|
||||
fetchUserProgress(username),
|
||||
fetchChallenges(),
|
||||
fetchLeaderboards()
|
||||
]);
|
||||
|
||||
// Update streak (marks user as active today)
|
||||
await updateUserStreak(username);
|
||||
|
||||
// Render all gamification components
|
||||
renderStreakIndicator();
|
||||
renderUserStats();
|
||||
renderChallenges();
|
||||
renderLeaderboards();
|
||||
|
||||
console.log('🎮 Gamification features initialized');
|
||||
} catch (error) {
|
||||
console.error('Error initializing gamification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user progress data
|
||||
async function fetchUserProgress(username) {
|
||||
try {
|
||||
const response = await fetch(`/getUserProgress?username=${encodeURIComponent(username)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user progress: ${response.status}`);
|
||||
}
|
||||
userProgress = await response.json();
|
||||
return userProgress;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user progress:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch challenges data
|
||||
async function fetchChallenges() {
|
||||
try {
|
||||
const response = await fetch('/getChallenges');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch challenges: ${response.status}`);
|
||||
}
|
||||
challenges = await response.json();
|
||||
return challenges;
|
||||
} catch (error) {
|
||||
console.error('Error fetching challenges:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch leaderboards data
|
||||
async function fetchLeaderboards() {
|
||||
try {
|
||||
const response = await fetch('/getLeaderboards');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch leaderboards: ${response.status}`);
|
||||
}
|
||||
leaderboards = await response.json();
|
||||
return leaderboards;
|
||||
} catch (error) {
|
||||
console.error('Error fetching leaderboards:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user streak
|
||||
async function updateUserStreak(username) {
|
||||
try {
|
||||
const response = await fetch('/updateStreak', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update streak: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update local data
|
||||
if (userProgress) {
|
||||
userProgress.streaks = result.streak;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error updating streak:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Complete a challenge
|
||||
async function completeChallenge(challengeId, challengeType) {
|
||||
if (!currentUser) {
|
||||
console.error('No user logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/completeChallenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: currentUser,
|
||||
challengeId,
|
||||
challengeType
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to complete challenge: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update local data
|
||||
userProgress = result.updatedProgress;
|
||||
|
||||
// Update UI
|
||||
renderUserStats();
|
||||
renderChallenges();
|
||||
|
||||
// Show success message
|
||||
showToast('Challenge completed!', 'success');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error completing challenge:', error);
|
||||
showToast('Failed to complete challenge', 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render streak indicator in student panel
|
||||
function renderStreakIndicator() {
|
||||
const streakContainer = document.getElementById('streakContainer');
|
||||
if (!streakContainer || !userProgress) return;
|
||||
|
||||
// Clear previous content
|
||||
streakContainer.innerHTML = '';
|
||||
|
||||
const currentStreak = userProgress.streaks.current || 0;
|
||||
|
||||
// Create streak element
|
||||
const streakEl = document.createElement('div');
|
||||
streakEl.className = 'streak-container';
|
||||
|
||||
streakEl.innerHTML = `
|
||||
<div class="streak-flame">🔥</div>
|
||||
<div class="streak-info">
|
||||
<div class="streak-days">${currentStreak} Day${currentStreak === 1 ? '' : 's'}</div>
|
||||
<div class="streak-subtitle">${currentStreak > 0 ? 'Current streak' : 'Start your streak today!'}</div>
|
||||
<div class="streak-progress">
|
||||
${Array.from({ length: 7 }, (_, i) =>
|
||||
`<div class="streak-day${i < currentStreak ? ' active' : ''}"></div>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="streak-record">
|
||||
<div class="streak-subtitle">Best</div>
|
||||
<div class="streak-best">${userProgress.streaks.longest || 0}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
streakContainer.appendChild(streakEl);
|
||||
}
|
||||
|
||||
// Render user stats in student panel
|
||||
function renderUserStats() {
|
||||
const statsContainer = document.getElementById('userStatsContainer');
|
||||
if (!statsContainer || !userProgress) return;
|
||||
|
||||
// Clear previous content
|
||||
statsContainer.innerHTML = '';
|
||||
|
||||
// Create stats container
|
||||
const statsEl = document.createElement('div');
|
||||
statsEl.className = 'user-stats';
|
||||
|
||||
// Add stat cards
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.points || 0}</div>
|
||||
<div class="stat-label">Points</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.accuracy ? userProgress.stats.accuracy.toFixed(1) + '%' : '0%'}</div>
|
||||
<div class="stat-label">Accuracy</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.totalSessions || 0}</div>
|
||||
<div class="stat-label">Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.totalWords || 0}</div>
|
||||
<div class="stat-label">Words</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
statsContainer.appendChild(statsEl);
|
||||
}
|
||||
|
||||
// Render challenges in student panel
|
||||
function renderChallenges() {
|
||||
const challengesContainer = document.getElementById('challengesContainer');
|
||||
if (!challengesContainer || !challenges || !userProgress) return;
|
||||
|
||||
// Clear previous content
|
||||
challengesContainer.innerHTML = '';
|
||||
|
||||
// Create challenges container
|
||||
const challengesEl = document.createElement('div');
|
||||
challengesEl.className = 'challenges-container';
|
||||
|
||||
// Daily challenge
|
||||
if (challenges.daily && challenges.daily.current) {
|
||||
const dailyChallenge = challenges.daily.current;
|
||||
const isCompleted = userProgress.challengesCompleted.daily.includes(dailyChallenge.id);
|
||||
|
||||
challengesEl.appendChild(createChallengeCard(dailyChallenge, 'daily', isCompleted));
|
||||
}
|
||||
|
||||
// Weekly challenge
|
||||
if (challenges.weekly && challenges.weekly.current) {
|
||||
const weeklyChallenge = challenges.weekly.current;
|
||||
const isCompleted = userProgress.challengesCompleted.weekly.includes(weeklyChallenge.id);
|
||||
|
||||
challengesEl.appendChild(createChallengeCard(weeklyChallenge, 'weekly', isCompleted));
|
||||
}
|
||||
|
||||
// Achievement challenges (show only 3 unearned ones)
|
||||
if (challenges.achievements && challenges.achievements.length > 0) {
|
||||
const uncompletedAchievements = challenges.achievements
|
||||
.filter(achievement => !userProgress.challengesCompleted.achievements.includes(achievement.id))
|
||||
.slice(0, 3);
|
||||
|
||||
uncompletedAchievements.forEach(achievement => {
|
||||
challengesEl.appendChild(createChallengeCard(achievement, 'achievement', false));
|
||||
});
|
||||
}
|
||||
|
||||
challengesContainer.appendChild(challengesEl);
|
||||
}
|
||||
|
||||
// Create a challenge card element
|
||||
function createChallengeCard(challenge, type, isCompleted) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `challenge-card ${type}`;
|
||||
card.dataset.id = challenge.id;
|
||||
|
||||
// Calculate progress for the challenge
|
||||
let progress = 0;
|
||||
let progressText = '';
|
||||
|
||||
switch (challenge.type) {
|
||||
case 'accuracy':
|
||||
progress = userProgress.stats.accuracy ? Math.min(userProgress.stats.accuracy / challenge.target, 1) : 0;
|
||||
progressText = `${userProgress.stats.accuracy ? userProgress.stats.accuracy.toFixed(1) : 0}% / ${challenge.target}%`;
|
||||
break;
|
||||
case 'words':
|
||||
case 'correctWords':
|
||||
progress = Math.min(userProgress.stats.correctWords / challenge.target, 1);
|
||||
progressText = `${userProgress.stats.correctWords || 0} / ${challenge.target}`;
|
||||
break;
|
||||
case 'sessions':
|
||||
progress = Math.min(userProgress.stats.totalSessions / challenge.target, 1);
|
||||
progressText = `${userProgress.stats.totalSessions || 0} / ${challenge.target}`;
|
||||
break;
|
||||
case 'streak':
|
||||
progress = Math.min(userProgress.streaks.current / challenge.target, 1);
|
||||
progressText = `${userProgress.streaks.current || 0} / ${challenge.target}`;
|
||||
break;
|
||||
case 'perfectSessions':
|
||||
// Would need to track this separately
|
||||
progress = 0;
|
||||
progressText = `0 / ${challenge.target}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Format date for challenge time period
|
||||
let timeframe = '';
|
||||
if (type === 'daily') {
|
||||
timeframe = `Today (${new Date(challenge.date).toLocaleDateString()})`;
|
||||
} else if (type === 'weekly') {
|
||||
timeframe = `${new Date(challenge.startDate).toLocaleDateString()} - ${new Date(challenge.endDate).toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="challenge-header">
|
||||
<h3 class="challenge-title">${challenge.title}</h3>
|
||||
<span class="challenge-badge ${type}">${
|
||||
type === 'daily' ? 'Daily'
|
||||
: type === 'weekly' ? 'Weekly'
|
||||
: 'Achievement'
|
||||
}</span>
|
||||
</div>
|
||||
<div class="challenge-description">${challenge.description}</div>
|
||||
<div class="challenge-progress">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill ${type}" style="width: ${Math.round(progress * 100)}%"></div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.85rem;">
|
||||
<span>${progressText}</span>
|
||||
<span>${Math.round(progress * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="challenge-footer">
|
||||
<div class="challenge-meta">
|
||||
${timeframe ? `<div>${timeframe}</div>` : ''}
|
||||
<div class="challenge-reward">
|
||||
<span>Reward:</span>
|
||||
<span class="challenge-points">+${challenge.reward.points} pts</span>
|
||||
${challenge.reward.badge ? `<span class="badge-icon">🏅 ${challenge.reward.badge}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="challenge-complete-btn${isCompleted ? ' completed' : progress >= 1 ? '' : ' locked'}"
|
||||
${isCompleted ? 'disabled' : ''}
|
||||
onclick="${isCompleted ? '' : progress >= 1 ? `completeChallenge('${challenge.id}', '${type === 'achievement' ? 'achievements' : type}')` : ''}"
|
||||
>
|
||||
${isCompleted ? 'Completed ✓' : progress >= 1 ? 'Claim Reward' : 'In Progress...'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// Render leaderboards in student panel
|
||||
function renderLeaderboards() {
|
||||
const leaderboardsContainer = document.getElementById('leaderboardsContainer');
|
||||
if (!leaderboardsContainer || !leaderboards) return;
|
||||
|
||||
// Clear previous content
|
||||
leaderboardsContainer.innerHTML = '';
|
||||
|
||||
// Create leaderboard container
|
||||
const leaderboardEl = document.createElement('div');
|
||||
leaderboardEl.className = 'leaderboard-container';
|
||||
|
||||
// Create header with tabs
|
||||
const header = document.createElement('div');
|
||||
header.className = 'leaderboard-header';
|
||||
header.innerHTML = `
|
||||
<h3>Leaderboards</h3>
|
||||
<div class="leaderboard-tabs">
|
||||
<div class="leaderboard-tab active" data-category="accuracy">Accuracy</div>
|
||||
<div class="leaderboard-tab" data-category="totalWords">Words</div>
|
||||
<div class="leaderboard-tab" data-category="streakDays">Streaks</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
leaderboardEl.appendChild(header);
|
||||
|
||||
// Create leaderboard table container
|
||||
const tableContainer = document.createElement('div');
|
||||
tableContainer.id = 'leaderboardTableContainer';
|
||||
|
||||
// Initially show accuracy leaderboard
|
||||
tableContainer.appendChild(createLeaderboardTable('accuracy'));
|
||||
|
||||
leaderboardEl.appendChild(tableContainer);
|
||||
leaderboardsContainer.appendChild(leaderboardEl);
|
||||
|
||||
// Add event listeners for tabs
|
||||
const tabs = header.querySelectorAll('.leaderboard-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', function() {
|
||||
// Remove active class from all tabs
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked tab
|
||||
this.classList.add('active');
|
||||
|
||||
// Update table
|
||||
const category = this.dataset.category;
|
||||
tableContainer.innerHTML = '';
|
||||
tableContainer.appendChild(createLeaderboardTable(category));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create a leaderboard table for a specific category
|
||||
function createLeaderboardTable(category) {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'leaderboard-table';
|
||||
|
||||
// Create table header
|
||||
const thead = document.createElement('thead');
|
||||
thead.innerHTML = `
|
||||
<tr>
|
||||
<th class="leaderboard-rank">#</th>
|
||||
<th>User</th>
|
||||
<th class="leaderboard-value">${
|
||||
category === 'accuracy' ? 'Accuracy'
|
||||
: category === 'totalWords' ? 'Words'
|
||||
: category === 'sessionsCompleted' ? 'Sessions'
|
||||
: 'Streak Days'
|
||||
}</th>
|
||||
</tr>
|
||||
`;
|
||||
table.appendChild(thead);
|
||||
|
||||
// Create table body
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
// Get leaderboard data
|
||||
const data = leaderboards.allTime[category] || [];
|
||||
|
||||
if (data.length === 0) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td colspan="3" style="text-align: center;">No data available</td>';
|
||||
tbody.appendChild(tr);
|
||||
} else {
|
||||
data.forEach((entry, index) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class="leaderboard-rank">${index + 1}</td>
|
||||
<td>${entry.username}${entry.username === currentUser ? ' (You)' : ''}</td>
|
||||
<td class="leaderboard-value">${
|
||||
category === 'accuracy' ? entry.value.toFixed(1) + '%'
|
||||
: entry.value
|
||||
}</td>
|
||||
`;
|
||||
|
||||
// Highlight current user
|
||||
if (entry.username === currentUser) {
|
||||
tr.style.backgroundColor = 'var(--primary-soft)';
|
||||
}
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
}
|
||||
|
||||
// Helper function to show toast messages
|
||||
function showToast(message, type = 'info') {
|
||||
// Check if toast container exists
|
||||
let toastContainer = document.querySelector('.toast-container');
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.className = 'toast-container';
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
// Create toast
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
// Add to container
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
// Show toast with animation
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// Remove after delay
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Expose functions to window for use in HTML
|
||||
window.completeChallenge = completeChallenge;
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
// Global variables
|
||||
let spacedRepetitionData = null;
|
||||
let currentUser = null;
|
||||
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) {
|
||||
currentUser = e.detail.username;
|
||||
initializeSpacedRepetition(currentUser);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 (!currentUser || !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 (!currentUser || !spacedRepetitionData) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/updateSpacedRepetitionData', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: currentUser,
|
||||
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 (!currentUser || !spacedRepetitionData) return;
|
||||
|
||||
try {
|
||||
// Get user's word lists
|
||||
const response = await fetch(`/getWordlistsForUser?username=${encodeURIComponent(currentUser)}`);
|
||||
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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user