Implement version 3.0.0 with Analytics Dashboard, Gamification Expansion, and Spaced Repetition System

This commit is contained in:
nate@necloud.us committed 2025-08-13 12:43:18 -04:00
1 parent 27af564a41
commit 7d8470df84
20 files changed
+22999 -23

No files matched your search

+346
View File
@@ -0,0 +1,346 @@
/* Gamification CSS for Spelling Practice App */
/* Leaderboard styles */
.leaderboard-container {
margin-top: 1rem;
}
.leaderboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.leaderboard-tabs {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.leaderboard-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
cursor: pointer;
background-color: var(--bg-secondary);
transition: all 0.2s ease;
}
.leaderboard-tab.active {
background-color: var(--primary);
color: white;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
border-radius: var(--radius);
overflow: hidden;
}
.leaderboard-table th {
background-color: var(--bg-secondary);
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
}
.leaderboard-table tr:nth-child(even) {
background-color: var(--bg-secondary);
}
.leaderboard-table tr:hover {
background-color: var(--bg-hover);
}
.leaderboard-table td {
padding: 0.75rem 1rem;
}
.leaderboard-rank {
font-weight: 700;
text-align: center;
width: 3rem;
}
.leaderboard-value {
font-weight: 600;
text-align: right;
}
.leaderboard-date {
font-size: 0.85rem;
color: var(--text-secondary);
text-align: right;
}
/* Top ranks */
.leaderboard-table tr:nth-child(1) .leaderboard-rank {
color: gold;
}
.leaderboard-table tr:nth-child(2) .leaderboard-rank {
color: silver;
}
.leaderboard-table tr:nth-child(3) .leaderboard-rank {
color: #cd7f32; /* bronze */
}
/* Challenges styles */
.challenges-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.challenge-card {
background-color: var(--bg-secondary);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
transition: all 0.2s ease;
}
.challenge-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow);
}
.challenge-card.daily {
border-left: 4px solid var(--primary);
}
.challenge-card.weekly {
border-left: 4px solid var(--accent);
}
.challenge-card.achievement {
border-left: 4px solid var(--success);
}
.challenge-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.challenge-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.challenge-badge {
font-size: 0.9rem;
padding: 0.25rem 0.75rem;
border-radius: 99px;
background-color: var(--bg-primary);
}
.challenge-badge.daily {
background-color: var(--primary-soft);
color: var(--primary);
}
.challenge-badge.weekly {
background-color: var(--accent-soft);
color: var(--accent);
}
.challenge-badge.achievement {
background-color: var(--success-soft);
color: var(--success);
}
.challenge-description {
margin: 0.5rem 0;
color: var(--text-secondary);
}
.challenge-progress {
margin-top: 1rem;
}
.progress-bar-container {
width: 100%;
height: 0.5rem;
background-color: var(--bg-primary);
border-radius: var(--radius);
overflow: hidden;
margin: 0.5rem 0;
}
.progress-bar-fill {
height: 100%;
background-color: var(--primary);
transition: width 0.3s ease;
}
.progress-bar-fill.daily {
background-color: var(--primary);
}
.progress-bar-fill.weekly {
background-color: var(--accent);
}
.progress-bar-fill.achievement {
background-color: var(--success);
}
.challenge-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.challenge-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.challenge-reward {
display: flex;
align-items: center;
gap: 0.5rem;
}
.challenge-points {
font-weight: 600;
color: var(--primary);
}
.challenge-complete-btn {
padding: 0.5rem 1rem;
background-color: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.challenge-complete-btn:hover {
background-color: var(--primary-hover);
transform: translateY(-2px);
}
.challenge-complete-btn:active {
transform: translateY(0);
}
.challenge-complete-btn.completed {
background-color: var(--success);
pointer-events: none;
}
.challenge-complete-btn.locked {
background-color: var(--bg-primary);
color: var(--text-secondary);
cursor: not-allowed;
}
/* User profile stats */
.user-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background-color: var(--bg-secondary);
border-radius: var(--radius);
padding: 1.25rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
margin: 0.25rem 0;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Streak indicator */
.streak-container {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--bg-secondary);
border-radius: var(--radius);
padding: 0.75rem 1.25rem;
margin-bottom: 1.5rem;
}
.streak-flame {
font-size: 1.5rem;
}
.streak-info {
flex: 1;
}
.streak-days {
font-weight: 600;
font-size: 1.1rem;
}
.streak-subtitle {
font-size: 0.85rem;
color: var(--text-secondary);
}
.streak-progress {
display: flex;
gap: 0.35rem;
margin-top: 0.5rem;
}
.streak-day {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
background-color: var(--bg-primary);
}
.streak-day.active {
background-color: var(--primary);
}
/* Achievements grid */
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.user-stats {
grid-template-columns: repeat(2, 1fr);
}
.challenge-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.challenge-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
+50
View File
@@ -9,6 +9,8 @@
<link href="tab-contrast.css" rel="stylesheet"/>
<link href="badges.css" rel="stylesheet"/>
<link href="analytics.css" rel="stylesheet"/>
<link href="gamification.css" rel="stylesheet"/>
<link href="spaced-repetition.css" rel="stylesheet"/>
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
<script src="js/auth.js"></script>
@@ -22,6 +24,8 @@
<script src="js/sessions.js"></script>
<script src="js/admin-badges.js"></script>
<script src="js/analytics.js"></script>
<script src="js/gamification.js"></script>
<script src="js/spaced-repetition.js"></script>
<script src="js/main.js"></script>
</head>
<body>
@@ -459,6 +463,12 @@
<p style="color: var(--text-secondary);">Choose an activity to start practicing</p>
</div>
<!-- Streak & User Stats Section -->
<div class="dashboard-section">
<div id="streakContainer" class="card dashboard-card"></div>
<div id="userStatsContainer" class="card dashboard-card"></div>
</div>
<!-- Theme Selection for Students -->
<div class="card" style="margin-bottom: 1.5rem;">
<h4>🎨 Choose Your Theme</h4>
@@ -484,6 +494,46 @@
</button>
</div>
</div>
<!-- Challenges Section -->
<div class="card dashboard-section" style="margin-top: 1.5rem;">
<div class="section-header">
<h3>🎯 Active Challenges</h3>
</div>
<div id="challengesContainer" class="challenges-section"></div>
</div>
<!-- Leaderboards Section -->
<div class="card dashboard-section" style="margin-top: 1.5rem;">
<div class="section-header">
<h3>🏆 Leaderboards</h3>
</div>
<div id="leaderboardsContainer" class="leaderboards-section"></div>
</div>
<!-- Spaced Repetition Section -->
<div class="card dashboard-section" style="margin-top: 1.5rem;">
<div class="section-header">
<h3>📚 Spaced Repetition</h3>
</div>
<div class="sr-info">
<h4>How Spaced Repetition Works</h4>
<p>Words you find difficult will appear more frequently, while words you know well will appear less often. This helps you learn more efficiently!</p>
</div>
<!-- SR Stats -->
<div id="srStatsContainer" class="sr-stats-container"></div>
<!-- SR Actions -->
<div class="sr-actions">
<button id="startSpacedReviewBtn" class="btn-primary" onclick="startSpacedReview()">🔄 Start Review</button>
<button class="btn-secondary" onclick="importWordsToSR()">📥 Import Words</button>
</div>
<!-- SR Progress -->
<div id="srProgressContainer" class="sr-progress-container"></div>
</div>
</div>
</div>
+56 -18
View File
@@ -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');
+501
View File
@@ -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;
+391
View File
@@ -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);
}
});
+233
View File
@@ -0,0 +1,233 @@
/* Spaced Repetition System Styles */
.sr-container {
margin-top: 1.5rem;
}
.sr-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.sr-stat-card {
background-color: var(--card-bg);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
transition: transform 0.2s ease;
}
.sr-stat-card:hover {
transform: translateY(-3px);
}
.sr-stat-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 0.3rem;
}
.sr-stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.sr-actions {
display: flex;
gap: 1rem;
margin: 1.5rem 0;
justify-content: center;
}
.sr-actions button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.sr-actions button.disabled {
opacity: 0.7;
cursor: not-allowed;
}
.sr-progress-visualization {
margin-top: 1.5rem;
}
.sr-progress-bar {
height: 24px;
border-radius: 12px;
overflow: hidden;
display: flex;
width: 100%;
background-color: var(--card-bg);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.sr-progress-segment {
height: 100%;
transition: width 0.3s ease;
}
.sr-legend {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 0.75rem;
}
.sr-legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.sr-legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
display: inline-block;
}
.sr-legend-label {
color: var(--text-secondary);
}
.sr-calendar {
margin-top: 2rem;
}
.sr-calendar-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 1rem;
text-align: center;
}
.sr-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
}
.sr-calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.sr-calendar-header-cell {
text-align: center;
font-weight: 600;
font-size: 0.8rem;
color: var(--text-secondary);
}
.sr-calendar-day {
aspect-ratio: 1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
position: relative;
background-color: var(--card-bg);
cursor: default;
}
.sr-calendar-day.has-reviews {
background-color: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.sr-calendar-day.today {
border: 2px solid var(--primary);
color: var(--primary);
font-weight: 700;
}
.sr-calendar-day .count {
position: absolute;
top: 2px;
right: 2px;
font-size: 0.7rem;
background-color: var(--primary);
color: white;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.sr-no-words {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.sr-import-section {
margin-top: 2rem;
text-align: center;
}
.sr-info {
background-color: var(--info-light);
border-left: 4px solid var(--info);
padding: 1rem;
border-radius: 4px;
margin: 1.5rem 0;
}
.sr-info h4 {
color: var(--info);
margin-top: 0;
margin-bottom: 0.5rem;
}
.sr-info p {
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.sr-difficulty {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.sr-difficulty-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.sr-difficulty-bar {
flex: 1;
height: 6px;
background-color: #eee;
border-radius: 3px;
overflow: hidden;
}
.sr-difficulty-fill {
height: 100%;
background: linear-gradient(to right, #1dd1a1, #feca57, #ff6b6b);
}