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
+22986
-10
No files matched your search
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"daily": [
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"name": "Practice Makes Perfect",
|
||||
"description": "Complete 10 practice words in a day",
|
||||
"type": "daily",
|
||||
"target": 10,
|
||||
"points": 50,
|
||||
"icon": "🎯"
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"name": "Sharp Shooter",
|
||||
"description": "Achieve 90% accuracy in daily practice",
|
||||
"type": "daily",
|
||||
"target": 0.9,
|
||||
"points": 75,
|
||||
"icon": "🎯"
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"name": "On Fire",
|
||||
"description": "Get 3 words correct in a row",
|
||||
"type": "daily",
|
||||
"target": 3,
|
||||
"points": 30,
|
||||
"icon": "🔥"
|
||||
}
|
||||
],
|
||||
"weekly": [
|
||||
{
|
||||
"id": "weekly-words-50",
|
||||
"name": "Word Warrior",
|
||||
"description": "Practice 50 words in a week",
|
||||
"type": "weekly",
|
||||
"target": 50,
|
||||
"points": 200,
|
||||
"icon": "⚔️"
|
||||
},
|
||||
{
|
||||
"id": "weekly-days-5",
|
||||
"name": "Dedicated Scholar",
|
||||
"description": "Practice on 5 different days this week",
|
||||
"type": "weekly",
|
||||
"target": 5,
|
||||
"points": 250,
|
||||
"icon": "📚"
|
||||
}
|
||||
],
|
||||
"achievements": [
|
||||
{
|
||||
"id": "achievement-words-100",
|
||||
"name": "Century Club",
|
||||
"description": "Practice 100 total words",
|
||||
"type": "achievement",
|
||||
"target": 100,
|
||||
"points": 100,
|
||||
"icon": "🏆"
|
||||
},
|
||||
{
|
||||
"id": "achievement-words-500",
|
||||
"name": "Word Master",
|
||||
"description": "Practice 500 total words",
|
||||
"type": "achievement",
|
||||
"target": 500,
|
||||
"points": 500,
|
||||
"icon": "👑"
|
||||
},
|
||||
{
|
||||
"id": "achievement-streak-7",
|
||||
"name": "Weekly Warrior",
|
||||
"description": "Maintain a 7-day practice streak",
|
||||
"type": "achievement",
|
||||
"target": 7,
|
||||
"points": 300,
|
||||
"icon": "📅"
|
||||
},
|
||||
{
|
||||
"id": "achievement-accuracy-95",
|
||||
"name": "Spelling Bee Champion",
|
||||
"description": "Maintain 95% accuracy across 100 words",
|
||||
"type": "achievement",
|
||||
"target": 0.95,
|
||||
"points": 400,
|
||||
"icon": "🐝"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
{
|
||||
"allTime": {
|
||||
"accuracy": [
|
||||
{
|
||||
"username": "benjamina31",
|
||||
"value": 94.4,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "loganm87",
|
||||
"value": 93.4,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "williamh77",
|
||||
"value": 90,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "logant84",
|
||||
"value": 88.8,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "sophiaa25",
|
||||
"value": 87.5,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "emmaa72",
|
||||
"value": 86.7,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "jacobg47",
|
||||
"value": 84.6,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "oliverb12",
|
||||
"value": 81.7,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"value": 79,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "admin1",
|
||||
"value": 77.1,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "testuser",
|
||||
"value": 77.1,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "nate",
|
||||
"value": 76.3,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "ameliab34",
|
||||
"value": 74.5,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "masonl4",
|
||||
"value": 73.1,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
}
|
||||
],
|
||||
"totalWords": [
|
||||
{
|
||||
"username": "nate",
|
||||
"value": 1352,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "logant84",
|
||||
"value": 966,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "williamh77",
|
||||
"value": 846,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "oliverb12",
|
||||
"value": 835,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"value": 819,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "testuser",
|
||||
"value": 761,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "ameliab34",
|
||||
"value": 743,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "admin1",
|
||||
"value": 724,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "masonl4",
|
||||
"value": 530,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "sophiaa25",
|
||||
"value": 419,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "benjamina31",
|
||||
"value": 344,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "emmaa72",
|
||||
"value": 304,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "loganm87",
|
||||
"value": 252,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "jacobg47",
|
||||
"value": 110,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
}
|
||||
],
|
||||
"sessionsCompleted": [
|
||||
{
|
||||
"username": "nate",
|
||||
"value": 129,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"value": 80,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "admin1",
|
||||
"value": 73,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "testuser",
|
||||
"value": 71,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "logant84",
|
||||
"value": 45,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "benjamina31",
|
||||
"value": 41,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "jacobg47",
|
||||
"value": 37,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "loganm87",
|
||||
"value": 33,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "sophiaa25",
|
||||
"value": 31,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "emmaa72",
|
||||
"value": 28,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "masonl4",
|
||||
"value": 22,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "williamh77",
|
||||
"value": 21,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "ameliab34",
|
||||
"value": 9,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "oliverb12",
|
||||
"value": 7,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
}
|
||||
],
|
||||
"streakDays": [
|
||||
{
|
||||
"username": "nate",
|
||||
"value": 18,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "loganm87",
|
||||
"value": 14,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "logant84",
|
||||
"value": 12,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "benjamina31",
|
||||
"value": 12,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"value": 10,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "sophiaa25",
|
||||
"value": 10,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "williamh77",
|
||||
"value": 9,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "oliverb12",
|
||||
"value": 8,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "testuser",
|
||||
"value": 7,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "ameliab34",
|
||||
"value": 7,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "admin1",
|
||||
"value": 6,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "jacobg47",
|
||||
"value": 2,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "emmaa72",
|
||||
"value": 1,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "masonl4",
|
||||
"value": 1,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"weekly": {
|
||||
"accuracy": [
|
||||
{
|
||||
"username": "benjamina31",
|
||||
"value": 98.2,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "loganm87",
|
||||
"value": 95.80000000000001,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "logant84",
|
||||
"value": 89.8,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "emmaa72",
|
||||
"value": 89.3,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "williamh77",
|
||||
"value": 88.8,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "sophiaa25",
|
||||
"value": 87.5,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"value": 83.7,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "jacobg47",
|
||||
"value": 81.19999999999999,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "oliverb12",
|
||||
"value": 80.10000000000001,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "nate",
|
||||
"value": 77.2,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "ameliab34",
|
||||
"value": 76.9,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "masonl4",
|
||||
"value": 75.39999999999999,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "testuser",
|
||||
"value": 74.1,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "admin1",
|
||||
"value": 73.19999999999999,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
}
|
||||
],
|
||||
"totalWords": [
|
||||
{
|
||||
"username": "logant84",
|
||||
"value": 53,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "masonl4",
|
||||
"value": 52,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "oliverb12",
|
||||
"value": 50,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "ameliab34",
|
||||
"value": 40,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "admin1",
|
||||
"value": 35,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "sophiaa25",
|
||||
"value": 34,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"value": 33,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "testuser",
|
||||
"value": 32,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "benjamina31",
|
||||
"value": 31,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "nate",
|
||||
"value": 21,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "emmaa72",
|
||||
"value": 19,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "loganm87",
|
||||
"value": 17,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "williamh77",
|
||||
"value": 15,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
},
|
||||
{
|
||||
"username": "jacobg47",
|
||||
"value": 11,
|
||||
"timestamp": "2025-08-13T16:18:54.828Z"
|
||||
}
|
||||
],
|
||||
"sessionsCompleted": [
|
||||
{
|
||||
"username": "logant84",
|
||||
"value": 7,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "admin1",
|
||||
"value": 6,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "loganm87",
|
||||
"value": 5,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "emmaa72",
|
||||
"value": 5,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "jacobg47",
|
||||
"value": 5,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "benjamina31",
|
||||
"value": 5,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "masonl4",
|
||||
"value": 4,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "williamh77",
|
||||
"value": 3,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "nate",
|
||||
"value": 2,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"value": 2,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "sophiaa25",
|
||||
"value": 2,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "testuser",
|
||||
"value": 1,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "ameliab34",
|
||||
"value": 1,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
},
|
||||
{
|
||||
"username": "oliverb12",
|
||||
"value": 1,
|
||||
"timestamp": "2025-08-13T16:18:54.829Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"currentWeek": "2025-3"
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
{
|
||||
"admin1": {
|
||||
"streak": {
|
||||
"current": 5,
|
||||
"longest": 11,
|
||||
"lastPractice": "2025-08-12"
|
||||
},
|
||||
"points": 1017,
|
||||
"level": 3,
|
||||
"dailyChallenges": [
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-13",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-12",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-12",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-12",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-10",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-09",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-09",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-09",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-08",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-08",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-08",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-07",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-07",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-07",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-05",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-05",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-02",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-07-31",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-07-31",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-07-31",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"weeklyChallenges": [
|
||||
{
|
||||
"id": "weekly-days-5",
|
||||
"weekStart": "2025-07-30",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "weekly-words-50",
|
||||
"weekStart": "2025-07-23",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "weekly-days-5",
|
||||
"weekStart": "2025-07-16",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"achievements": [
|
||||
{
|
||||
"id": "achievement-words-100",
|
||||
"date": "2025-07-22",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "achievement-words-500",
|
||||
"date": "2025-08-01",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"wordsAttempted": 574,
|
||||
"wordsCorrect": 415.86,
|
||||
"sessionsCompleted": 69,
|
||||
"averageAccuracy": 0.9
|
||||
}
|
||||
},
|
||||
"nate": {
|
||||
"streak": {
|
||||
"current": 2,
|
||||
"longest": 12,
|
||||
"lastPractice": "2025-08-12"
|
||||
},
|
||||
"points": 543,
|
||||
"level": 2,
|
||||
"dailyChallenges": [
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-13",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-13",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-11",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-11",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-09",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-09",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-07",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-07",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-05",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-05",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-03",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-03",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-02",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"weeklyChallenges": [
|
||||
{
|
||||
"id": "weekly-words-50",
|
||||
"weekStart": "2025-07-23",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"achievements": [
|
||||
{
|
||||
"id": "achievement-words-100",
|
||||
"date": "2025-08-01",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"wordsAttempted": 817,
|
||||
"wordsCorrect": 317.68,
|
||||
"sessionsCompleted": 79,
|
||||
"averageAccuracy": 0.95
|
||||
}
|
||||
},
|
||||
"student1": {
|
||||
"streak": {
|
||||
"current": 6,
|
||||
"longest": 9,
|
||||
"lastPractice": "2025-08-12"
|
||||
},
|
||||
"points": 574,
|
||||
"level": 2,
|
||||
"dailyChallenges": [
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-13",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-12",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-12",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-11",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-11",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-10",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-05",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-05",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-03",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-02",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-01",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-01",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-07-31",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-07-31",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-07-31",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"weeklyChallenges": [
|
||||
{
|
||||
"id": "weekly-words-50",
|
||||
"weekStart": "2025-08-06",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"achievements": [
|
||||
{
|
||||
"id": "achievement-words-100",
|
||||
"date": "2025-07-23",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"wordsAttempted": 102,
|
||||
"wordsCorrect": 648.1800000000001,
|
||||
"sessionsCompleted": 65,
|
||||
"averageAccuracy": 0.78
|
||||
}
|
||||
},
|
||||
"testuser": {
|
||||
"streak": {
|
||||
"current": 13,
|
||||
"longest": 20,
|
||||
"lastPractice": "2025-08-12"
|
||||
},
|
||||
"points": 843,
|
||||
"level": 2,
|
||||
"dailyChallenges": [
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-13",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-13",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-12",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-11",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-11",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-11",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-09",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-09",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-08",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-07",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-05",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-04",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-02",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-practice-10",
|
||||
"date": "2025-08-01",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-accuracy-90",
|
||||
"date": "2025-08-01",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "daily-streak-3",
|
||||
"date": "2025-08-01",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"weeklyChallenges": [
|
||||
{
|
||||
"id": "weekly-words-50",
|
||||
"weekStart": "2025-08-06",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "weekly-words-50",
|
||||
"weekStart": "2025-07-30",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"achievements": [
|
||||
{
|
||||
"id": "achievement-words-100",
|
||||
"date": "2025-08-03",
|
||||
"progress": 1
|
||||
},
|
||||
{
|
||||
"id": "achievement-words-500",
|
||||
"date": "2025-07-18",
|
||||
"progress": 1
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"wordsAttempted": 353,
|
||||
"wordsCorrect": 789.4499999999999,
|
||||
"sessionsCompleted": 87,
|
||||
"averageAccuracy": 0.88
|
||||
}
|
||||
}
|
||||
}
|
||||
+17805
-2
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,300 @@
|
||||
{
|
||||
"nate": {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 15,
|
||||
"correctReviews": 9,
|
||||
"totalWords": 6,
|
||||
"masteredWords": 0
|
||||
},
|
||||
"words": {
|
||||
"computer": {
|
||||
"easeFactor": 2.5,
|
||||
"interval": 3,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"keyboard": {
|
||||
"easeFactor": 2.3,
|
||||
"interval": 2,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"mouse": {
|
||||
"easeFactor": 2.8,
|
||||
"interval": 4,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-09",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"screen": {
|
||||
"easeFactor": 3,
|
||||
"interval": 6,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-08-07",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"printer": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"not": {
|
||||
"easeFactor": 2.6,
|
||||
"interval": 7,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-20"
|
||||
},
|
||||
"bot": {
|
||||
"easeFactor": 2.8,
|
||||
"interval": 15,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-05",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"snot": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"bate": {
|
||||
"easeFactor": 2.1,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"hate": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 3,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-15"
|
||||
},
|
||||
"late": {
|
||||
"easeFactor": 2.5,
|
||||
"interval": 15,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-11"
|
||||
}
|
||||
}
|
||||
},
|
||||
"student1": {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 10,
|
||||
"correctReviews": 5,
|
||||
"totalWords": 5,
|
||||
"masteredWords": 0
|
||||
},
|
||||
"words": {
|
||||
"apple": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 3,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-10"
|
||||
},
|
||||
"banana": {
|
||||
"easeFactor": 2.1,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"cherry": {
|
||||
"easeFactor": 2.4,
|
||||
"interval": 3,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-16"
|
||||
},
|
||||
"date": {
|
||||
"easeFactor": 2.5,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"elderberry": {
|
||||
"easeFactor": 2.3,
|
||||
"interval": 15,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-11"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin1": {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 12,
|
||||
"correctReviews": 9,
|
||||
"totalWords": 3,
|
||||
"masteredWords": 1
|
||||
},
|
||||
"words": {
|
||||
"administration": {
|
||||
"easeFactor": 2.5,
|
||||
"interval": 3,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"education": {
|
||||
"easeFactor": 2.8,
|
||||
"interval": 4,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-09",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"technology": {
|
||||
"easeFactor": 2.3,
|
||||
"interval": 2,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"development": {
|
||||
"easeFactor": 3,
|
||||
"interval": 6,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-08-07",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"programming": {
|
||||
"easeFactor": 3,
|
||||
"interval": 6,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-08-07",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"boy": {
|
||||
"easeFactor": 2.7,
|
||||
"interval": 15,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-09",
|
||||
"nextReview": "2025-08-20"
|
||||
},
|
||||
"bot": {
|
||||
"easeFactor": 2.6,
|
||||
"interval": 40,
|
||||
"repetitions": 6,
|
||||
"lastReview": "2025-07-23",
|
||||
"nextReview": "2025-08-25"
|
||||
},
|
||||
"snot": {
|
||||
"easeFactor": 2.4,
|
||||
"interval": 3,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-10"
|
||||
}
|
||||
}
|
||||
},
|
||||
"testuser": {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 32,
|
||||
"correctReviews": 23,
|
||||
"totalWords": 10,
|
||||
"masteredWords": 3
|
||||
},
|
||||
"words": {
|
||||
"hello": {
|
||||
"easeFactor": 2.4,
|
||||
"interval": 7,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-07",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"world": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 3,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"test": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 7,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-08",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"sample": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 3,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-16"
|
||||
},
|
||||
"basic": {
|
||||
"easeFactor": 2.7,
|
||||
"interval": 40,
|
||||
"repetitions": 6,
|
||||
"lastReview": "2025-08-03",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"learn": {
|
||||
"easeFactor": 2.4,
|
||||
"interval": 0,
|
||||
"repetitions": 0,
|
||||
"lastReview": null,
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"spell": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"word": {
|
||||
"easeFactor": 2.9,
|
||||
"interval": 20,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-07-27",
|
||||
"nextReview": "2025-08-27"
|
||||
},
|
||||
"study": {
|
||||
"easeFactor": 2.4,
|
||||
"interval": 7,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-08",
|
||||
"nextReview": "2025-08-14"
|
||||
},
|
||||
"practice": {
|
||||
"easeFactor": 2.8,
|
||||
"interval": 80,
|
||||
"repetitions": 7,
|
||||
"lastReview": "2025-07-06",
|
||||
"nextReview": "2025-08-14"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
// Gamification API endpoints for the Spelling Practice App
|
||||
|
||||
// 🏆 Get leaderboards
|
||||
app.get('/getLeaderboards', (req, res) => {
|
||||
try {
|
||||
const leaderboards = readJsonSafe(path.join(DATA_DIR, files.leaderboards), {});
|
||||
res.json(leaderboards);
|
||||
} catch (error) {
|
||||
console.error('Error getting leaderboards:', error);
|
||||
res.status(500).send('Failed to get leaderboards');
|
||||
}
|
||||
});
|
||||
|
||||
// 🎯 Get current challenges
|
||||
app.get('/getChallenges', (req, res) => {
|
||||
try {
|
||||
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||
res.json(challenges);
|
||||
} catch (error) {
|
||||
console.error('Error getting challenges:', error);
|
||||
res.status(500).send('Failed to get challenges');
|
||||
}
|
||||
});
|
||||
|
||||
// 📈 Get user progress
|
||||
app.get('/getUserProgress', (req, res) => {
|
||||
try {
|
||||
const { username } = req.query;
|
||||
if (!username) {
|
||||
return res.status(400).send('Username is required');
|
||||
}
|
||||
|
||||
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||
|
||||
if (!progress[username]) {
|
||||
return res.status(404).send('User progress not found');
|
||||
}
|
||||
|
||||
res.json(progress[username]);
|
||||
} catch (error) {
|
||||
console.error('Error getting user progress:', error);
|
||||
res.status(500).send('Failed to get user progress');
|
||||
}
|
||||
});
|
||||
|
||||
// 📋 Get all users' progress
|
||||
app.get('/getAllProgress', (req, res) => {
|
||||
try {
|
||||
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||
res.json(progress);
|
||||
} catch (error) {
|
||||
console.error('Error getting all progress data:', error);
|
||||
res.status(500).send('Failed to get progress data');
|
||||
}
|
||||
});
|
||||
|
||||
// 🔄 Update user streak
|
||||
app.post('/updateStreak', (req, res) => {
|
||||
try {
|
||||
const { username } = req.body;
|
||||
if (!username) {
|
||||
return res.status(400).send('Username is required');
|
||||
}
|
||||
|
||||
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||
|
||||
if (!progress[username]) {
|
||||
return res.status(404).send('User progress not found');
|
||||
}
|
||||
|
||||
const userProgress = progress[username];
|
||||
const now = new Date();
|
||||
const lastActivity = userProgress.streaks.lastActivity ? new Date(userProgress.streaks.lastActivity) : null;
|
||||
|
||||
// If this is the first activity or it's a new day
|
||||
if (!lastActivity || !isToday(lastActivity)) {
|
||||
// Check if the streak continues (last activity was yesterday)
|
||||
if (lastActivity) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (isSameDay(lastActivity, yesterday)) {
|
||||
// Streak continues
|
||||
userProgress.streaks.current++;
|
||||
} else {
|
||||
// Streak broken
|
||||
userProgress.streaks.current = 1;
|
||||
}
|
||||
} else {
|
||||
// First activity
|
||||
userProgress.streaks.current = 1;
|
||||
}
|
||||
|
||||
// Update longest streak if needed
|
||||
if (userProgress.streaks.current > userProgress.streaks.longest) {
|
||||
userProgress.streaks.longest = userProgress.streaks.current;
|
||||
}
|
||||
|
||||
// Update last activity
|
||||
userProgress.streaks.lastActivity = now.toISOString();
|
||||
|
||||
// Write updated progress
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.progress), JSON.stringify(progress, null, 2));
|
||||
|
||||
// Check for streak achievements
|
||||
checkStreakAchievements(username, userProgress.streaks.current);
|
||||
}
|
||||
|
||||
res.json({ streak: userProgress.streaks });
|
||||
} catch (error) {
|
||||
console.error('Error updating streak:', error);
|
||||
res.status(500).send('Failed to update streak');
|
||||
}
|
||||
});
|
||||
|
||||
// 📝 Complete challenge
|
||||
app.post('/completeChallenge', (req, res) => {
|
||||
try {
|
||||
const { username, challengeId, challengeType } = req.body;
|
||||
if (!username || !challengeId || !challengeType) {
|
||||
return res.status(400).send('Username, challengeId, and challengeType are required');
|
||||
}
|
||||
|
||||
// Valid challenge types
|
||||
if (!['daily', 'weekly', 'achievements'].includes(challengeType)) {
|
||||
return res.status(400).send('Invalid challenge type');
|
||||
}
|
||||
|
||||
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||
|
||||
if (!progress[username]) {
|
||||
return res.status(404).send('User progress not found');
|
||||
}
|
||||
|
||||
// Find the challenge
|
||||
let challenge = null;
|
||||
if (challengeType === 'achievements') {
|
||||
challenge = challenges.achievements?.find(c => c.id === challengeId);
|
||||
} else {
|
||||
challenge = challenges[challengeType]?.current?.id === challengeId
|
||||
? challenges[challengeType].current
|
||||
: challenges[challengeType]?.history?.find(c => c.id === challengeId);
|
||||
}
|
||||
|
||||
if (!challenge) {
|
||||
return res.status(404).send('Challenge not found');
|
||||
}
|
||||
|
||||
// Check if already completed
|
||||
if (progress[username].challengesCompleted[challengeType].includes(challengeId)) {
|
||||
return res.status(409).send('Challenge already completed');
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
progress[username].challengesCompleted[challengeType].push(challengeId);
|
||||
|
||||
// Award points
|
||||
if (challenge.reward && challenge.reward.points) {
|
||||
progress[username].stats.points += challenge.reward.points;
|
||||
}
|
||||
|
||||
// Award badge if applicable
|
||||
if (challenge.reward && challenge.reward.badge) {
|
||||
const badges = readJsonSafe(path.join(DATA_DIR, files.badges), {});
|
||||
|
||||
if (!badges[username]) {
|
||||
badges[username] = [];
|
||||
}
|
||||
|
||||
if (!badges[username].includes(challenge.reward.badge)) {
|
||||
badges[username].push(challenge.reward.badge);
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.badges), JSON.stringify(badges, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated progress
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.progress), JSON.stringify(progress, null, 2));
|
||||
|
||||
res.json({
|
||||
message: 'Challenge completed',
|
||||
updatedProgress: progress[username]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error completing challenge:', error);
|
||||
res.status(500).send('Failed to complete challenge');
|
||||
}
|
||||
});
|
||||
|
||||
// 🔄 Update leaderboards
|
||||
app.post('/updateLeaderboards', (req, res) => {
|
||||
try {
|
||||
// This should be an admin-only endpoint
|
||||
const leaderboards = readJsonSafe(path.join(DATA_DIR, files.leaderboards), {});
|
||||
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||
|
||||
// Update all-time leaderboards
|
||||
const accuracyLeaderboard = [];
|
||||
const wordsLeaderboard = [];
|
||||
const sessionsLeaderboard = [];
|
||||
const streaksLeaderboard = [];
|
||||
|
||||
Object.entries(progress).forEach(([username, data]) => {
|
||||
if (data.stats.totalSessions > 0) {
|
||||
// Accuracy leaderboard
|
||||
accuracyLeaderboard.push({
|
||||
username,
|
||||
value: data.stats.accuracy,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Words leaderboard
|
||||
wordsLeaderboard.push({
|
||||
username,
|
||||
value: data.stats.totalWords,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Sessions leaderboard
|
||||
sessionsLeaderboard.push({
|
||||
username,
|
||||
value: data.stats.totalSessions,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Streaks leaderboard
|
||||
streaksLeaderboard.push({
|
||||
username,
|
||||
value: data.streaks.current,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort and limit leaderboards
|
||||
leaderboards.allTime.accuracy = accuracyLeaderboard
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
leaderboards.allTime.totalWords = wordsLeaderboard
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
leaderboards.allTime.sessionsCompleted = sessionsLeaderboard
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
leaderboards.allTime.streakDays = streaksLeaderboard
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
// TODO: Implement weekly leaderboard calculations
|
||||
|
||||
leaderboards.lastUpdated = new Date().toISOString();
|
||||
|
||||
// Write updated leaderboards
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.leaderboards), JSON.stringify(leaderboards, null, 2));
|
||||
|
||||
res.json({ message: 'Leaderboards updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating leaderboards:', error);
|
||||
res.status(500).send('Failed to update leaderboards');
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 Generate new daily challenge
|
||||
app.post('/generateDailyChallenge', (req, res) => {
|
||||
try {
|
||||
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||
const today = new Date();
|
||||
const dayString = today.toISOString().split('T')[0];
|
||||
|
||||
// Check if today's challenge already exists
|
||||
if (challenges.daily.current && challenges.daily.current.date === dayString) {
|
||||
return res.status(409).send('Today\'s challenge already exists');
|
||||
}
|
||||
|
||||
// Move current challenge to history
|
||||
if (challenges.daily.current) {
|
||||
if (!challenges.daily.history) {
|
||||
challenges.daily.history = [];
|
||||
}
|
||||
challenges.daily.history.unshift(challenges.daily.current);
|
||||
|
||||
// Limit history size
|
||||
if (challenges.daily.history.length > 30) {
|
||||
challenges.daily.history = challenges.daily.history.slice(0, 30);
|
||||
}
|
||||
}
|
||||
|
||||
// Challenge types
|
||||
const challengeTypes = [
|
||||
{
|
||||
type: 'accuracy',
|
||||
description: 'Complete a spelling session with at least {target}% accuracy',
|
||||
target: Math.floor(Math.random() * 11) + 85, // 85-95% accuracy
|
||||
points: 50
|
||||
},
|
||||
{
|
||||
type: 'words',
|
||||
description: 'Spell {target} words correctly in one day',
|
||||
target: Math.floor(Math.random() * 16) + 10, // 10-25 words
|
||||
points: 40
|
||||
},
|
||||
{
|
||||
type: 'sessions',
|
||||
description: 'Complete {target} spelling sessions in one day',
|
||||
target: Math.floor(Math.random() * 3) + 2, // 2-4 sessions
|
||||
points: 60
|
||||
}
|
||||
];
|
||||
|
||||
// Choose random challenge type
|
||||
const challenge = challengeTypes[Math.floor(Math.random() * challengeTypes.length)];
|
||||
|
||||
// Create new challenge
|
||||
challenges.daily.current = {
|
||||
id: `daily-${dayString}`,
|
||||
title: `${today.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} Challenge`,
|
||||
description: challenge.description.replace('{target}', challenge.target),
|
||||
type: challenge.type,
|
||||
target: challenge.target,
|
||||
reward: {
|
||||
points: challenge.points,
|
||||
badge: null
|
||||
},
|
||||
date: dayString
|
||||
};
|
||||
|
||||
challenges.lastUpdated = today.toISOString();
|
||||
|
||||
// Write updated challenges
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.challenges), JSON.stringify(challenges, null, 2));
|
||||
|
||||
res.json({ message: 'Daily challenge generated', challenge: challenges.daily.current });
|
||||
} catch (error) {
|
||||
console.error('Error generating daily challenge:', error);
|
||||
res.status(500).send('Failed to generate daily challenge');
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 Generate new weekly challenge
|
||||
app.post('/generateWeeklyChallenge', (req, res) => {
|
||||
try {
|
||||
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||
const today = new Date();
|
||||
const weekNum = getWeekNumber(today);
|
||||
const yearNum = today.getFullYear();
|
||||
const weekId = `weekly-${yearNum}-${weekNum}`;
|
||||
|
||||
// Check if this week's challenge already exists
|
||||
if (challenges.weekly.current && challenges.weekly.current.id === weekId) {
|
||||
return res.status(409).send('This week\'s challenge already exists');
|
||||
}
|
||||
|
||||
// Move current challenge to history
|
||||
if (challenges.weekly.current) {
|
||||
if (!challenges.weekly.history) {
|
||||
challenges.weekly.history = [];
|
||||
}
|
||||
challenges.weekly.history.unshift(challenges.weekly.current);
|
||||
|
||||
// Limit history size
|
||||
if (challenges.weekly.history.length > 12) {
|
||||
challenges.weekly.history = challenges.weekly.history.slice(0, 12);
|
||||
}
|
||||
}
|
||||
|
||||
// Challenge types
|
||||
const challengeTypes = [
|
||||
{
|
||||
type: 'streak',
|
||||
description: 'Practice spelling for {target} consecutive days',
|
||||
target: Math.floor(Math.random() * 3) + 4, // 4-6 days
|
||||
points: 100,
|
||||
badge: 'Weekly Warrior'
|
||||
},
|
||||
{
|
||||
type: 'correctWords',
|
||||
description: 'Spell {target} words correctly this week',
|
||||
target: Math.floor(Math.random() * 51) + 75, // 75-125 words
|
||||
points: 150,
|
||||
badge: 'Word Master'
|
||||
},
|
||||
{
|
||||
type: 'perfectSessions',
|
||||
description: 'Complete {target} perfect spelling sessions this week',
|
||||
target: Math.floor(Math.random() * 3) + 2, // 2-4 sessions
|
||||
points: 200,
|
||||
badge: 'Perfect Form'
|
||||
}
|
||||
];
|
||||
|
||||
// Choose random challenge type
|
||||
const challenge = challengeTypes[Math.floor(Math.random() * challengeTypes.length)];
|
||||
|
||||
// Create new challenge
|
||||
challenges.weekly.current = {
|
||||
id: weekId,
|
||||
title: `Week ${weekNum} Challenge`,
|
||||
description: challenge.description.replace('{target}', challenge.target),
|
||||
type: challenge.type,
|
||||
target: challenge.target,
|
||||
reward: {
|
||||
points: challenge.points,
|
||||
badge: challenge.badge
|
||||
},
|
||||
startDate: getWeekStartDate(today).toISOString().split('T')[0],
|
||||
endDate: getWeekEndDate(today).toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
challenges.lastUpdated = today.toISOString();
|
||||
|
||||
// Write updated challenges
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.challenges), JSON.stringify(challenges, null, 2));
|
||||
|
||||
res.json({ message: 'Weekly challenge generated', challenge: challenges.weekly.current });
|
||||
} catch (error) {
|
||||
console.error('Error generating weekly challenge:', error);
|
||||
res.status(500).send('Failed to generate weekly challenge');
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to check for streak achievements
|
||||
function checkStreakAchievements(username, currentStreak) {
|
||||
try {
|
||||
const challenges = readJsonSafe(path.join(DATA_DIR, files.challenges), {});
|
||||
const progress = readJsonSafe(path.join(DATA_DIR, files.progress), {});
|
||||
|
||||
// Find streak-based achievements
|
||||
const streakAchievements = challenges.achievements.filter(
|
||||
a => a.type === 'streak' && a.condition === 'dailyLogin'
|
||||
);
|
||||
|
||||
// Check if any achievement is unlocked by the current streak
|
||||
streakAchievements.forEach(achievement => {
|
||||
// If streak target met and not already completed
|
||||
if (currentStreak >= achievement.target &&
|
||||
!progress[username].challengesCompleted.achievements.includes(achievement.id)) {
|
||||
|
||||
// Mark as completed
|
||||
progress[username].challengesCompleted.achievements.push(achievement.id);
|
||||
|
||||
// Award points
|
||||
if (achievement.reward && achievement.reward.points) {
|
||||
progress[username].stats.points += achievement.reward.points;
|
||||
}
|
||||
|
||||
// Award badge if applicable
|
||||
if (achievement.reward && achievement.reward.badge) {
|
||||
const badges = readJsonSafe(path.join(DATA_DIR, files.badges), {});
|
||||
|
||||
if (!badges[username]) {
|
||||
badges[username] = [];
|
||||
}
|
||||
|
||||
if (!badges[username].includes(achievement.reward.badge)) {
|
||||
badges[username].push(achievement.reward.badge);
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.badges), JSON.stringify(badges, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Write updated progress if needed
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.progress), JSON.stringify(progress, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error checking streak achievements:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Generate sample analytics data for the Spelling Practice App
|
||||
* This script will add realistic user activity data to showcase the analytics features
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
results: path.join(DATA_DIR, 'results.json'),
|
||||
progress: path.join(DATA_DIR, 'progress.json'),
|
||||
leaderboards: path.join(DATA_DIR, 'leaderboards.json'),
|
||||
challenges: path.join(DATA_DIR, 'challenges.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 2) {
|
||||
const rand = Math.random() * (max - min) + min;
|
||||
return parseFloat(rand.toFixed(decimals));
|
||||
}
|
||||
|
||||
// Date helpers
|
||||
function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function subtractDays(date, days) {
|
||||
return addDays(date, -days);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function generateTimeOfDay() {
|
||||
const hours = randomInt(8, 20).toString().padStart(2, '0');
|
||||
const minutes = randomInt(0, 59).toString().padStart(2, '0');
|
||||
const seconds = randomInt(0, 59).toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Main function to generate analytics data
|
||||
async function generateAnalyticsData() {
|
||||
console.log('Generating sample analytics data...');
|
||||
|
||||
// Load users
|
||||
const users = readJsonSafe(files.users, []);
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate data for each user
|
||||
const usernames = users.map(user => user.username);
|
||||
|
||||
// Generate 90 days of results data (covering 3 months)
|
||||
const today = new Date();
|
||||
|
||||
// Load existing data
|
||||
const resultsData = readJsonSafe(files.results, {});
|
||||
const progressData = readJsonSafe(files.progress, {});
|
||||
|
||||
// For each user, generate 90 days of activity
|
||||
for (const username of usernames) {
|
||||
console.log(`Generating data for user: ${username}`);
|
||||
|
||||
// Initialize user results if not exists
|
||||
if (!resultsData[username]) {
|
||||
resultsData[username] = [];
|
||||
}
|
||||
|
||||
// Initialize user progress if not exists
|
||||
if (!progressData[username]) {
|
||||
progressData[username] = {
|
||||
stats: {
|
||||
points: 0,
|
||||
totalSessions: 0,
|
||||
totalWords: 0,
|
||||
correctWords: 0,
|
||||
accuracy: 0
|
||||
},
|
||||
streaks: {
|
||||
current: 0,
|
||||
longest: 0,
|
||||
dates: []
|
||||
},
|
||||
challengesCompleted: {
|
||||
daily: [],
|
||||
weekly: [],
|
||||
achievements: []
|
||||
},
|
||||
activityLog: []
|
||||
};
|
||||
}
|
||||
|
||||
// Track total stats
|
||||
let totalSessions = 0;
|
||||
let totalWords = 0;
|
||||
let totalCorrect = 0;
|
||||
let streakDates = [];
|
||||
let activityLog = [];
|
||||
let points = randomInt(100, 500);
|
||||
|
||||
// Generate 90 days of activity with increasing frequency towards present day
|
||||
for (let daysAgo = 90; daysAgo >= 0; daysAgo--) {
|
||||
const activityDate = subtractDays(today, daysAgo);
|
||||
const dateStr = formatDate(activityDate);
|
||||
|
||||
// Determine likelihood of activity on this day
|
||||
// Higher chance of activity for more recent dates and on weekdays
|
||||
const dayOfWeek = activityDate.getDay(); // 0 = Sunday, 6 = Saturday
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
const recencyFactor = 1 - (daysAgo / 90); // 0 to 1 from oldest to newest
|
||||
|
||||
// Combine factors to determine chance of activity
|
||||
// 30% base chance + up to 50% recency bonus - 20% weekend penalty
|
||||
let activityChance = 0.3 + (recencyFactor * 0.5) - (isWeekend ? 0.2 : 0);
|
||||
|
||||
// For user "nate", increase activity chance to ensure good data
|
||||
if (username === 'nate') {
|
||||
activityChance += 0.2;
|
||||
}
|
||||
|
||||
// Determine if there's activity on this day
|
||||
if (Math.random() < activityChance) {
|
||||
// Record streak date
|
||||
streakDates.push(dateStr);
|
||||
|
||||
// Determine number of sessions for this day (1-3)
|
||||
const sessionsToday = randomInt(1, username === 'nate' ? 3 : 2);
|
||||
|
||||
for (let s = 0; s < sessionsToday; s++) {
|
||||
// Generate session ID
|
||||
const sessionId = `${username}-${dateStr}-${s}`;
|
||||
|
||||
// Determine session details
|
||||
const numWords = randomInt(5, 15);
|
||||
|
||||
// Base accuracy that improves over time due to learning
|
||||
let baseAccuracy = 0.65 + (recencyFactor * 0.2);
|
||||
|
||||
// Add some randomness
|
||||
const accuracy = Math.min(0.98, baseAccuracy + randomFloat(-0.1, 0.1));
|
||||
|
||||
// Calculate correct words
|
||||
const correctWords = Math.round(numWords * accuracy);
|
||||
|
||||
// Generate random words
|
||||
const words = Array.from({length: numWords}, (_, i) => {
|
||||
const isCorrect = i < correctWords;
|
||||
return {
|
||||
word: `sample-word-${randomInt(1, 100)}`,
|
||||
correct: isCorrect
|
||||
};
|
||||
});
|
||||
|
||||
// Create session result
|
||||
const sessionResult = {
|
||||
date: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
sessionId,
|
||||
total: numWords,
|
||||
correct: correctWords,
|
||||
score: Math.round(accuracy * 100),
|
||||
words
|
||||
};
|
||||
|
||||
// Add to results
|
||||
resultsData[username].push(sessionResult);
|
||||
|
||||
// Update totals
|
||||
totalSessions++;
|
||||
totalWords += numWords;
|
||||
totalCorrect += correctWords;
|
||||
|
||||
// Add to activity log
|
||||
activityLog.push({
|
||||
type: 'session_completed',
|
||||
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
details: {
|
||||
sessionId,
|
||||
score: Math.round(accuracy * 100),
|
||||
words: numWords
|
||||
}
|
||||
});
|
||||
|
||||
// Occasionally add badge earning (1 in 10 sessions)
|
||||
if (Math.random() < 0.1) {
|
||||
const badges = [
|
||||
"Perfect Round", "Speed Demon", "Word Wizard", "Consistent Learner",
|
||||
"Weekly Warrior", "Quick Study", "No Misses"
|
||||
];
|
||||
const badge = badges[randomInt(0, badges.length - 1)];
|
||||
|
||||
activityLog.push({
|
||||
type: 'badge_earned',
|
||||
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
details: {
|
||||
badge,
|
||||
sessionId
|
||||
}
|
||||
});
|
||||
|
||||
points += randomInt(10, 50);
|
||||
}
|
||||
|
||||
// Occasionally add challenge completion (1 in 8 sessions)
|
||||
if (Math.random() < 0.125) {
|
||||
const challengeTypes = ['daily', 'weekly', 'achievements'];
|
||||
const challengeType = challengeTypes[randomInt(0, challengeTypes.length - 1)];
|
||||
const challengeId = `${challengeType}-${dateStr}-${randomInt(1, 5)}`;
|
||||
|
||||
// Add to completed challenges
|
||||
if (!progressData[username].challengesCompleted[challengeType].includes(challengeId)) {
|
||||
progressData[username].challengesCompleted[challengeType].push(challengeId);
|
||||
}
|
||||
|
||||
activityLog.push({
|
||||
type: 'challenge_completed',
|
||||
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
details: {
|
||||
challengeId,
|
||||
challengeType,
|
||||
reward: {
|
||||
points: randomInt(10, 50)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
points += randomInt(20, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate streak stats
|
||||
const streakDays = streakDates.length;
|
||||
const currentStreak = calculateCurrentStreak(streakDates, today);
|
||||
const longestStreak = calculateLongestStreak(streakDates);
|
||||
|
||||
// Update progress data with calculated stats
|
||||
progressData[username].stats = {
|
||||
points,
|
||||
totalSessions,
|
||||
totalWords,
|
||||
correctWords: totalCorrect,
|
||||
accuracy: totalWords > 0 ? Math.round((totalCorrect / totalWords) * 1000) / 10 : 0
|
||||
};
|
||||
|
||||
progressData[username].streaks = {
|
||||
current: currentStreak,
|
||||
longest: longestStreak,
|
||||
dates: streakDates
|
||||
};
|
||||
|
||||
progressData[username].activityLog = activityLog;
|
||||
}
|
||||
|
||||
// Save updated data
|
||||
console.log('Saving updated results data...');
|
||||
writeJsonSafe(files.results, resultsData);
|
||||
|
||||
console.log('Saving updated progress data...');
|
||||
writeJsonSafe(files.progress, progressData);
|
||||
|
||||
console.log('Sample data generation complete!');
|
||||
}
|
||||
|
||||
// Helper to calculate current streak
|
||||
function calculateCurrentStreak(dates, today) {
|
||||
if (!dates || dates.length === 0) return 0;
|
||||
|
||||
// Convert dates to Date objects for easier comparison
|
||||
const sortedDates = [...dates].map(d => new Date(d)).sort((a, b) => b - a);
|
||||
|
||||
// Check if today is included
|
||||
const todayStr = formatDate(today);
|
||||
const hasTodayActivity = dates.includes(todayStr);
|
||||
|
||||
if (!hasTodayActivity) {
|
||||
// Check if yesterday is included
|
||||
const yesterday = subtractDays(today, 1);
|
||||
const yesterdayStr = formatDate(yesterday);
|
||||
const hasYesterdayActivity = dates.includes(yesterdayStr);
|
||||
|
||||
if (!hasYesterdayActivity) {
|
||||
return 0; // Streak broken if neither today nor yesterday has activity
|
||||
}
|
||||
}
|
||||
|
||||
// Count consecutive days
|
||||
let streak = hasTodayActivity ? 1 : 0;
|
||||
let currentDate = hasTodayActivity ? subtractDays(today, 1) : subtractDays(today, 2);
|
||||
|
||||
while (true) {
|
||||
const dateStr = formatDate(currentDate);
|
||||
if (!dates.includes(dateStr)) {
|
||||
break;
|
||||
}
|
||||
streak++;
|
||||
currentDate = subtractDays(currentDate, 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
// Helper to calculate longest streak
|
||||
function calculateLongestStreak(dates) {
|
||||
if (!dates || dates.length === 0) return 0;
|
||||
|
||||
// Convert dates to Date objects and sort
|
||||
const sortedDates = [...dates].map(d => new Date(d)).sort((a, b) => a - b);
|
||||
|
||||
let longestStreak = 1;
|
||||
let currentStreak = 1;
|
||||
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const prevDate = sortedDates[i - 1];
|
||||
const currentDate = sortedDates[i];
|
||||
|
||||
// Calculate difference in days
|
||||
const diffTime = currentDate.getTime() - prevDate.getTime();
|
||||
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
// Consecutive day
|
||||
currentStreak++;
|
||||
if (currentStreak > longestStreak) {
|
||||
longestStreak = currentStreak;
|
||||
}
|
||||
} else if (diffDays > 1) {
|
||||
// Streak broken
|
||||
currentStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return longestStreak;
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateAnalyticsData();
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Generate sample gamification data for the Spelling Practice App
|
||||
* This script will populate challenges and user progress with realistic data
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
challenges: path.join(DATA_DIR, 'challenges.json'),
|
||||
progress: path.join(DATA_DIR, 'progress.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
// Date helpers
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function subtractDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() - days);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate sample challenges
|
||||
function generateChallenges() {
|
||||
const challenges = {
|
||||
daily: [
|
||||
{
|
||||
id: "daily-practice-10",
|
||||
name: "Practice Makes Perfect",
|
||||
description: "Complete 10 practice words in a day",
|
||||
type: "daily",
|
||||
target: 10,
|
||||
points: 50,
|
||||
icon: "🎯"
|
||||
},
|
||||
{
|
||||
id: "daily-accuracy-90",
|
||||
name: "Sharp Shooter",
|
||||
description: "Achieve 90% accuracy in daily practice",
|
||||
type: "daily",
|
||||
target: 0.9,
|
||||
points: 75,
|
||||
icon: "🎯"
|
||||
},
|
||||
{
|
||||
id: "daily-streak-3",
|
||||
name: "On Fire",
|
||||
description: "Get 3 words correct in a row",
|
||||
type: "daily",
|
||||
target: 3,
|
||||
points: 30,
|
||||
icon: "🔥"
|
||||
}
|
||||
],
|
||||
weekly: [
|
||||
{
|
||||
id: "weekly-words-50",
|
||||
name: "Word Warrior",
|
||||
description: "Practice 50 words in a week",
|
||||
type: "weekly",
|
||||
target: 50,
|
||||
points: 200,
|
||||
icon: "⚔️"
|
||||
},
|
||||
{
|
||||
id: "weekly-days-5",
|
||||
name: "Dedicated Scholar",
|
||||
description: "Practice on 5 different days this week",
|
||||
type: "weekly",
|
||||
target: 5,
|
||||
points: 250,
|
||||
icon: "📚"
|
||||
}
|
||||
],
|
||||
achievements: [
|
||||
{
|
||||
id: "achievement-words-100",
|
||||
name: "Century Club",
|
||||
description: "Practice 100 total words",
|
||||
type: "achievement",
|
||||
target: 100,
|
||||
points: 100,
|
||||
icon: "🏆"
|
||||
},
|
||||
{
|
||||
id: "achievement-words-500",
|
||||
name: "Word Master",
|
||||
description: "Practice 500 total words",
|
||||
type: "achievement",
|
||||
target: 500,
|
||||
points: 500,
|
||||
icon: "👑"
|
||||
},
|
||||
{
|
||||
id: "achievement-streak-7",
|
||||
name: "Weekly Warrior",
|
||||
description: "Maintain a 7-day practice streak",
|
||||
type: "achievement",
|
||||
target: 7,
|
||||
points: 300,
|
||||
icon: "📅"
|
||||
},
|
||||
{
|
||||
id: "achievement-accuracy-95",
|
||||
name: "Spelling Bee Champion",
|
||||
description: "Maintain 95% accuracy across 100 words",
|
||||
type: "achievement",
|
||||
target: 0.95,
|
||||
points: 400,
|
||||
icon: "🐝"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return challenges;
|
||||
}
|
||||
|
||||
// Generate progress data for a user
|
||||
function generateUserProgress(username) {
|
||||
const today = new Date();
|
||||
|
||||
// Generate streak data
|
||||
const currentStreak = randomInt(1, 14);
|
||||
const longestStreak = Math.max(currentStreak, randomInt(7, 21));
|
||||
|
||||
// Generate points from challenges and achievements
|
||||
const totalPoints = randomInt(500, 2000);
|
||||
|
||||
// Generate completed challenges
|
||||
const dailyChallenges = [];
|
||||
const weeklyChallenges = [];
|
||||
const achievements = [];
|
||||
|
||||
// Generate some daily challenges for the past 14 days
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = formatDate(subtractDays(today, i));
|
||||
|
||||
// 70% chance to have completed some daily challenges
|
||||
if (Math.random() < 0.7) {
|
||||
// Random selection of daily challenges
|
||||
const dailyIds = ["daily-practice-10", "daily-accuracy-90", "daily-streak-3"];
|
||||
const completedCount = randomInt(1, dailyIds.length);
|
||||
|
||||
for (let j = 0; j < completedCount; j++) {
|
||||
dailyChallenges.push({
|
||||
id: dailyIds[j],
|
||||
date,
|
||||
progress: 1.0 // 100% complete
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate weekly challenges for the past 4 weeks
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const weekStart = formatDate(subtractDays(today, 7 * (i + 1)));
|
||||
|
||||
// 60% chance to have completed a weekly challenge
|
||||
if (Math.random() < 0.6) {
|
||||
const weeklyIds = ["weekly-words-50", "weekly-days-5"];
|
||||
const completedId = weeklyIds[randomInt(0, weeklyIds.length - 1)];
|
||||
|
||||
weeklyChallenges.push({
|
||||
id: completedId,
|
||||
weekStart,
|
||||
progress: 1.0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate achievements (randomly completed)
|
||||
const achievementIds = [
|
||||
"achievement-words-100",
|
||||
"achievement-words-500",
|
||||
"achievement-streak-7",
|
||||
"achievement-accuracy-95"
|
||||
];
|
||||
|
||||
// 30-70% of achievements completed
|
||||
const completedCount = randomInt(
|
||||
Math.floor(achievementIds.length * 0.3),
|
||||
Math.floor(achievementIds.length * 0.7)
|
||||
);
|
||||
|
||||
for (let i = 0; i < completedCount; i++) {
|
||||
const achievementId = achievementIds[i];
|
||||
const daysAgo = randomInt(1, 30);
|
||||
|
||||
achievements.push({
|
||||
id: achievementId,
|
||||
date: formatDate(subtractDays(today, daysAgo)),
|
||||
progress: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
// Overall user progress data
|
||||
return {
|
||||
streak: {
|
||||
current: currentStreak,
|
||||
longest: longestStreak,
|
||||
lastPractice: formatDate(subtractDays(today, 1)) // Yesterday
|
||||
},
|
||||
points: totalPoints,
|
||||
level: Math.floor(totalPoints / 500) + 1, // Level up every 500 points
|
||||
dailyChallenges,
|
||||
weeklyChallenges,
|
||||
achievements,
|
||||
stats: {
|
||||
wordsAttempted: randomInt(100, 1000),
|
||||
wordsCorrect: randomInt(70, 95) / 100 * randomInt(100, 1000),
|
||||
sessionsCompleted: randomInt(20, 100),
|
||||
averageAccuracy: randomInt(70, 98) / 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Main function to generate gamification data
|
||||
async function generateGamificationData() {
|
||||
console.log('Generating sample gamification data...');
|
||||
|
||||
// Load users
|
||||
const users = readJsonSafe(files.users, []);
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate challenges
|
||||
console.log('Generating challenges data...');
|
||||
const challenges = generateChallenges();
|
||||
|
||||
// Generate progress for each user
|
||||
const progress = {};
|
||||
|
||||
for (const user of users) {
|
||||
const username = user.username;
|
||||
console.log(`Generating progress data for user: ${username}`);
|
||||
progress[username] = generateUserProgress(username);
|
||||
}
|
||||
|
||||
// Save data
|
||||
console.log('Saving challenges and progress data...');
|
||||
writeJsonSafe(files.challenges, challenges);
|
||||
writeJsonSafe(files.progress, progress);
|
||||
|
||||
console.log('Gamification data generation complete!');
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateGamificationData();
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Generate sample leaderboard data for the Spelling Practice App
|
||||
* This script will populate leaderboards with realistic data for the analytics dashboard
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
progress: path.join(DATA_DIR, 'progress.json'),
|
||||
leaderboards: path.join(DATA_DIR, 'leaderboards.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 2) {
|
||||
const rand = Math.random() * (max - min) + min;
|
||||
return parseFloat(rand.toFixed(decimals));
|
||||
}
|
||||
|
||||
// Generate additional fake users for the leaderboards
|
||||
function generateFakeUsers(count) {
|
||||
const firstNames = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'William', 'Sophia', 'James', 'Isabella', 'Logan',
|
||||
'Mia', 'Benjamin', 'Charlotte', 'Mason', 'Amelia', 'Elijah', 'Harper', 'Oliver', 'Evelyn', 'Jacob'];
|
||||
|
||||
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
|
||||
'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin'];
|
||||
|
||||
const users = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const firstName = firstNames[randomInt(0, firstNames.length - 1)];
|
||||
const lastName = lastNames[randomInt(0, lastNames.length - 1)];
|
||||
const username = `${firstName.toLowerCase()}${lastName.substring(0, 1).toLowerCase()}${randomInt(1, 99)}`;
|
||||
|
||||
users.push(username);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
// Main function to generate leaderboard data
|
||||
async function generateLeaderboardData() {
|
||||
console.log('Generating sample leaderboard data...');
|
||||
|
||||
// Load users and progress data
|
||||
const users = readJsonSafe(files.users, []);
|
||||
const progressData = readJsonSafe(files.progress, {});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate 10 additional fake users for leaderboards
|
||||
const fakeUsers = generateFakeUsers(10);
|
||||
const allUsers = [...users.map(u => u.username), ...fakeUsers];
|
||||
|
||||
// Create leaderboard data
|
||||
const leaderboards = {
|
||||
allTime: {
|
||||
accuracy: [],
|
||||
totalWords: [],
|
||||
sessionsCompleted: [],
|
||||
streakDays: []
|
||||
},
|
||||
weekly: {
|
||||
accuracy: [],
|
||||
totalWords: [],
|
||||
sessionsCompleted: []
|
||||
},
|
||||
currentWeek: `${new Date().getFullYear()}-${Math.ceil((new Date().getDate() + new Date().getDay()) / 7)}`
|
||||
};
|
||||
|
||||
// Generate data for each category
|
||||
|
||||
// All-time accuracy leaderboard
|
||||
allUsers.forEach(username => {
|
||||
// For real users, use their actual progress data if available
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.accuracy.push({
|
||||
username,
|
||||
value: progressData[username].stats.accuracy || randomFloat(70, 98, 1),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
// For fake users, generate random data
|
||||
leaderboards.allTime.accuracy.push({
|
||||
username,
|
||||
value: randomFloat(70, 98, 1),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by value (descending)
|
||||
leaderboards.allTime.accuracy.sort((a, b) => b.value - a.value);
|
||||
|
||||
// All-time totalWords leaderboard
|
||||
allUsers.forEach(username => {
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.totalWords.push({
|
||||
username,
|
||||
value: progressData[username].stats.totalWords || randomInt(100, 1000),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
leaderboards.allTime.totalWords.push({
|
||||
username,
|
||||
value: randomInt(100, 1000),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
leaderboards.allTime.totalWords.sort((a, b) => b.value - a.value);
|
||||
|
||||
// All-time sessionsCompleted leaderboard
|
||||
allUsers.forEach(username => {
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.sessionsCompleted.push({
|
||||
username,
|
||||
value: progressData[username].stats.totalSessions || randomInt(5, 50),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
leaderboards.allTime.sessionsCompleted.push({
|
||||
username,
|
||||
value: randomInt(5, 50),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
leaderboards.allTime.sessionsCompleted.sort((a, b) => b.value - a.value);
|
||||
|
||||
// All-time streakDays leaderboard
|
||||
allUsers.forEach(username => {
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.streakDays.push({
|
||||
username,
|
||||
value: progressData[username].streaks?.longest || randomInt(1, 14),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
leaderboards.allTime.streakDays.push({
|
||||
username,
|
||||
value: randomInt(1, 14),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
leaderboards.allTime.streakDays.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Generate weekly leaderboards
|
||||
// Copy from all-time but with different values
|
||||
|
||||
// Weekly accuracy
|
||||
allUsers.forEach(username => {
|
||||
const allTimeEntry = leaderboards.allTime.accuracy.find(entry => entry.username === username);
|
||||
const baseValue = allTimeEntry ? allTimeEntry.value : randomFloat(70, 98, 1);
|
||||
|
||||
// Weekly value is slightly different from all-time
|
||||
leaderboards.weekly.accuracy.push({
|
||||
username,
|
||||
value: Math.min(99.9, Math.max(50, baseValue + randomFloat(-5, 5, 1))),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
leaderboards.weekly.accuracy.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Weekly totalWords
|
||||
allUsers.forEach(username => {
|
||||
const allTimeEntry = leaderboards.allTime.totalWords.find(entry => entry.username === username);
|
||||
const baseValue = allTimeEntry ? allTimeEntry.value : randomInt(100, 1000);
|
||||
|
||||
// Weekly value is much smaller than all-time
|
||||
leaderboards.weekly.totalWords.push({
|
||||
username,
|
||||
value: randomInt(10, Math.min(100, Math.floor(baseValue / 10))),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
leaderboards.weekly.totalWords.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Weekly sessionsCompleted
|
||||
allUsers.forEach(username => {
|
||||
const allTimeEntry = leaderboards.allTime.sessionsCompleted.find(entry => entry.username === username);
|
||||
const baseValue = allTimeEntry ? allTimeEntry.value : randomInt(5, 50);
|
||||
|
||||
// Weekly value is much smaller than all-time
|
||||
leaderboards.weekly.sessionsCompleted.push({
|
||||
username,
|
||||
value: randomInt(1, Math.min(7, Math.floor(baseValue / 5))),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
leaderboards.weekly.sessionsCompleted.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Save the leaderboard data
|
||||
console.log('Saving leaderboard data...');
|
||||
writeJsonSafe(files.leaderboards, leaderboards);
|
||||
|
||||
console.log('Leaderboard data generation complete!');
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateLeaderboardData();
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Generate sample spaced repetition data for the Spelling Practice App
|
||||
* This script will populate the spaced repetition system with realistic learning data
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
wordlists: path.join(DATA_DIR, 'wordlists.json'),
|
||||
spacedRepetition: path.join(DATA_DIR, 'spacedRepetition.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 2) {
|
||||
const rand = Math.random() * (max - min) + min;
|
||||
return parseFloat(rand.toFixed(decimals));
|
||||
}
|
||||
|
||||
// Date helpers
|
||||
function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function subtractDays(date, days) {
|
||||
return addDays(date, -days);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Main function to generate spaced repetition data
|
||||
async function generateSpacedRepetitionData() {
|
||||
console.log('Generating sample spaced repetition data...');
|
||||
|
||||
// Load users and wordlists
|
||||
const users = readJsonSafe(files.users, []);
|
||||
const wordlists = readJsonSafe(files.wordlists, {});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create spaced repetition data structure
|
||||
const srData = readJsonSafe(files.spacedRepetition, {});
|
||||
|
||||
// Generate data for each user
|
||||
const today = new Date();
|
||||
|
||||
for (const user of users) {
|
||||
const username = user.username;
|
||||
console.log(`Generating spaced repetition data for user: ${username}`);
|
||||
|
||||
// Get user's words
|
||||
let userWords = [];
|
||||
|
||||
// Handle different wordlist structures
|
||||
if (Array.isArray(wordlists[username])) {
|
||||
userWords = wordlists[username];
|
||||
} else if (wordlists[username] && wordlists[username].weeks && Array.isArray(wordlists[username].weeks)) {
|
||||
// Extract all words from all weeks
|
||||
wordlists[username].weeks.forEach(week => {
|
||||
if (week.words && Array.isArray(week.words)) {
|
||||
userWords = [...userWords, ...week.words];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (userWords.length === 0) {
|
||||
console.log(`No words found for user ${username}. Skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize user's spaced repetition data if not exists
|
||||
if (!srData[username]) {
|
||||
srData[username] = {
|
||||
settings: {
|
||||
dailyLimit: 20,
|
||||
reviewThreshold: 3,
|
||||
newWordsPerDay: 5
|
||||
},
|
||||
stats: {
|
||||
totalReviews: 0,
|
||||
correctReviews: 0,
|
||||
totalWords: 0,
|
||||
masteredWords: 0
|
||||
},
|
||||
words: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Add words with different mastery levels
|
||||
let totalReviews = 0;
|
||||
let correctReviews = 0;
|
||||
let masteredWords = 0;
|
||||
|
||||
for (const word of userWords) {
|
||||
// Determine mastery level
|
||||
// 0: New - 20%
|
||||
// 1-2: Learning - 30%
|
||||
// 3-4: Reviewing - 30%
|
||||
// 5+: Mastered - 20%
|
||||
|
||||
const rand = Math.random();
|
||||
let repetitions = 0;
|
||||
let interval = 0;
|
||||
let easeFactor = 2.5;
|
||||
|
||||
if (rand < 0.2) {
|
||||
// New word
|
||||
repetitions = 0;
|
||||
interval = 0;
|
||||
easeFactor = randomFloat(2.3, 2.7, 1);
|
||||
} else if (rand < 0.5) {
|
||||
// Learning
|
||||
repetitions = randomInt(1, 2);
|
||||
interval = repetitions === 1 ? 1 : 3;
|
||||
easeFactor = randomFloat(2.0, 2.5, 1);
|
||||
totalReviews += repetitions;
|
||||
correctReviews += repetitions - 1;
|
||||
} else if (rand < 0.8) {
|
||||
// Reviewing
|
||||
repetitions = randomInt(3, 4);
|
||||
interval = repetitions === 3 ? 7 : 15;
|
||||
easeFactor = randomFloat(2.2, 2.8, 1);
|
||||
totalReviews += repetitions;
|
||||
correctReviews += repetitions - 1;
|
||||
} else {
|
||||
// Mastered
|
||||
repetitions = randomInt(5, 8);
|
||||
interval = Math.pow(2, repetitions - 4) * 10; // Exponential increase
|
||||
easeFactor = randomFloat(2.6, 3.0, 1);
|
||||
totalReviews += repetitions;
|
||||
correctReviews += repetitions - 1;
|
||||
masteredWords++;
|
||||
}
|
||||
|
||||
// Calculate last and next review dates
|
||||
let nextReview = null;
|
||||
let lastReview = null;
|
||||
|
||||
if (repetitions > 0) {
|
||||
// If the word has been reviewed before, set lastReview
|
||||
lastReview = formatDate(subtractDays(today, randomInt(1, interval)));
|
||||
|
||||
// Next review might be due
|
||||
const dueChance = 0.3; // 30% chance word is due
|
||||
if (Math.random() < dueChance) {
|
||||
// Word is due today or in the past
|
||||
nextReview = formatDate(subtractDays(today, randomInt(0, 3)));
|
||||
} else {
|
||||
// Word is due in the future
|
||||
nextReview = formatDate(addDays(today, randomInt(1, interval)));
|
||||
}
|
||||
} else {
|
||||
// New word, not reviewed yet
|
||||
nextReview = formatDate(today);
|
||||
}
|
||||
|
||||
// Add word to user's SR data
|
||||
srData[username].words[word] = {
|
||||
easeFactor,
|
||||
interval,
|
||||
repetitions,
|
||||
lastReview,
|
||||
nextReview
|
||||
};
|
||||
}
|
||||
|
||||
// Update user stats
|
||||
srData[username].stats = {
|
||||
totalReviews,
|
||||
correctReviews,
|
||||
totalWords: userWords.length,
|
||||
masteredWords
|
||||
};
|
||||
}
|
||||
|
||||
// Save spaced repetition data
|
||||
console.log('Saving spaced repetition data...');
|
||||
writeJsonSafe(files.spacedRepetition, srData);
|
||||
|
||||
console.log('Spaced repetition data generation complete!');
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateSpacedRepetitionData();
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "modern-spelling-practice",
|
||||
"version": "2.1.0",
|
||||
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, and analytics",
|
||||
"version": "3.0.0",
|
||||
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, analytics, gamification, and spaced repetition learning",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
+43
-5
@@ -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,7 +96,15 @@ window.startGame = async function() {
|
||||
document.getElementById('summary')?.classList.add('hidden');
|
||||
document.getElementById('badgeDisplay')?.classList.add('hidden');
|
||||
|
||||
// Fetch 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);
|
||||
@@ -108,16 +117,22 @@ window.startGame = async function() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!weekWords.length) {
|
||||
wordList = weekWords;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -177,6 +192,7 @@ function showSummary() {
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"nate": {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 15,
|
||||
"correctReviews": 12,
|
||||
"totalWords": 25,
|
||||
"masteredWords": 5
|
||||
},
|
||||
"words": {
|
||||
"computer": {
|
||||
"easeFactor": 2.5,
|
||||
"interval": 3,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"keyboard": {
|
||||
"easeFactor": 2.3,
|
||||
"interval": 2,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"mouse": {
|
||||
"easeFactor": 2.8,
|
||||
"interval": 4,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-09",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"screen": {
|
||||
"easeFactor": 3.0,
|
||||
"interval": 6,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-08-07",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"printer": {
|
||||
"easeFactor": 2.2,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-13"
|
||||
}
|
||||
}
|
||||
},
|
||||
"student1": {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 8,
|
||||
"correctReviews": 6,
|
||||
"totalWords": 10,
|
||||
"masteredWords": 2
|
||||
},
|
||||
"words": {
|
||||
"apple": {
|
||||
"easeFactor": 2.6,
|
||||
"interval": 3,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"banana": {
|
||||
"easeFactor": 2.4,
|
||||
"interval": 2,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"cherry": {
|
||||
"easeFactor": 2.7,
|
||||
"interval": 5,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-08",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"date": {
|
||||
"easeFactor": 3.0,
|
||||
"interval": 7,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-08-06",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"elderberry": {
|
||||
"easeFactor": 1.8,
|
||||
"interval": 1,
|
||||
"repetitions": 1,
|
||||
"lastReview": "2025-08-12",
|
||||
"nextReview": "2025-08-13"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin1": {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 10,
|
||||
"correctReviews": 9,
|
||||
"totalWords": 5,
|
||||
"masteredWords": 3
|
||||
},
|
||||
"words": {
|
||||
"administration": {
|
||||
"easeFactor": 2.5,
|
||||
"interval": 3,
|
||||
"repetitions": 3,
|
||||
"lastReview": "2025-08-10",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"education": {
|
||||
"easeFactor": 2.8,
|
||||
"interval": 4,
|
||||
"repetitions": 4,
|
||||
"lastReview": "2025-08-09",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"technology": {
|
||||
"easeFactor": 2.3,
|
||||
"interval": 2,
|
||||
"repetitions": 2,
|
||||
"lastReview": "2025-08-11",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"development": {
|
||||
"easeFactor": 3.0,
|
||||
"interval": 6,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-08-07",
|
||||
"nextReview": "2025-08-13"
|
||||
},
|
||||
"programming": {
|
||||
"easeFactor": 3.0,
|
||||
"interval": 6,
|
||||
"repetitions": 5,
|
||||
"lastReview": "2025-08-07",
|
||||
"nextReview": "2025-08-13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,67 @@ const DATA_DIR = path.join(__dirname, 'data');
|
||||
const SEED_DIR = path.join(__dirname, 'seed');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
|
||||
|
||||
// Date utility functions for gamification features
|
||||
function getWeekNumber(date) {
|
||||
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
||||
const pastDaysOfYear = (date - firstDayOfYear) / 86400000;
|
||||
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
||||
}
|
||||
|
||||
function getWeekStartDate(date) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() - result.getDay());
|
||||
return result;
|
||||
}
|
||||
|
||||
function getWeekEndDate(date) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + (6 - result.getDay()));
|
||||
return result;
|
||||
}
|
||||
|
||||
function isToday(dateString) {
|
||||
if (!dateString) return false;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return dateString.split('T')[0] === today;
|
||||
}
|
||||
|
||||
function isSameDay(date1, date2) {
|
||||
return date1.toISOString().split('T')[0] === date2.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function daysInARow(dateStrings) {
|
||||
if (!dateStrings || !dateStrings.length) return 0;
|
||||
|
||||
// Convert strings to Date objects
|
||||
const dates = dateStrings.map(d => new Date(d)).sort((a, b) => a - b);
|
||||
|
||||
let currentStreak = 1;
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
const prevDate = new Date(dates[i-1]);
|
||||
prevDate.setDate(prevDate.getDate() + 1);
|
||||
|
||||
// If consecutive days
|
||||
if (isSameDay(prevDate, dates[i])) {
|
||||
currentStreak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return currentStreak;
|
||||
}
|
||||
|
||||
// 🗃 File mapping
|
||||
const files = {
|
||||
users: 'users.json',
|
||||
wordlists: 'wordlists.json',
|
||||
results: 'results.json',
|
||||
badges: 'badges.json'
|
||||
badges: 'badges.json',
|
||||
leaderboards: 'leaderboards.json',
|
||||
challenges: 'challenges.json',
|
||||
progress: 'progress.json',
|
||||
spacedRepetition: 'spacedRepetition.json'
|
||||
};
|
||||
|
||||
// 🌱 Initialize data from seeds if missing/empty
|
||||
@@ -82,6 +137,156 @@ function ensureFileWithSeed(name, fallback = '{}') {
|
||||
};
|
||||
fs.writeFileSync(targetPath, JSON.stringify(defaultBadges, null, 2));
|
||||
console.log(`🏆 Created default badge structure`);
|
||||
} else if (name === 'leaderboards.json') {
|
||||
const defaultLeaderboards = {
|
||||
"allTime": {
|
||||
"accuracy": [],
|
||||
"totalWords": [],
|
||||
"sessionsCompleted": [],
|
||||
"streakDays": []
|
||||
},
|
||||
"weekly": {
|
||||
"accuracy": [],
|
||||
"totalWords": [],
|
||||
"sessionsCompleted": []
|
||||
},
|
||||
"lastUpdated": new Date().toISOString()
|
||||
};
|
||||
fs.writeFileSync(targetPath, JSON.stringify(defaultLeaderboards, null, 2));
|
||||
console.log(`🏅 Created default leaderboards structure`);
|
||||
} else if (name === 'challenges.json') {
|
||||
const today = new Date();
|
||||
const dayString = today.toISOString().split('T')[0];
|
||||
const weekNum = getWeekNumber(today);
|
||||
const yearNum = today.getFullYear();
|
||||
|
||||
const defaultChallenges = {
|
||||
"daily": {
|
||||
"current": {
|
||||
"id": `daily-${dayString}`,
|
||||
"title": `${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} Challenge`,
|
||||
"description": "Complete a spelling session with at least 90% accuracy",
|
||||
"type": "accuracy",
|
||||
"target": 90,
|
||||
"reward": {
|
||||
"points": 50,
|
||||
"badge": null
|
||||
},
|
||||
"date": dayString
|
||||
},
|
||||
"history": []
|
||||
},
|
||||
"weekly": {
|
||||
"current": {
|
||||
"id": `weekly-${yearNum}-${weekNum}`,
|
||||
"title": `Week ${weekNum} Challenge`,
|
||||
"description": "Practice spelling for 5 consecutive days",
|
||||
"type": "streak",
|
||||
"target": 5,
|
||||
"reward": {
|
||||
"points": 100,
|
||||
"badge": "Weekly Warrior"
|
||||
},
|
||||
"startDate": getWeekStartDate(today).toISOString().split('T')[0],
|
||||
"endDate": getWeekEndDate(today).toISOString().split('T')[0]
|
||||
},
|
||||
"history": []
|
||||
},
|
||||
"achievements": [
|
||||
{
|
||||
"id": "perfect-accuracy",
|
||||
"title": "Perfect Accuracy",
|
||||
"description": "Complete a session with 100% accuracy",
|
||||
"type": "session",
|
||||
"condition": "accuracy",
|
||||
"target": 100,
|
||||
"reward": {
|
||||
"points": 75,
|
||||
"badge": "Perfection"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "persistent-learner",
|
||||
"title": "Persistent Learner",
|
||||
"description": "Practice for 7 days in a row",
|
||||
"type": "streak",
|
||||
"condition": "dailyLogin",
|
||||
"target": 7,
|
||||
"reward": {
|
||||
"points": 200,
|
||||
"badge": "Persistence"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "spelling-veteran",
|
||||
"title": "Spelling Veteran",
|
||||
"description": "Complete 50 spelling sessions",
|
||||
"type": "cumulative",
|
||||
"condition": "sessions",
|
||||
"target": 50,
|
||||
"reward": {
|
||||
"points": 300,
|
||||
"badge": "Veteran"
|
||||
}
|
||||
}
|
||||
],
|
||||
"lastUpdated": new Date().toISOString()
|
||||
};
|
||||
fs.writeFileSync(targetPath, JSON.stringify(defaultChallenges, null, 2));
|
||||
console.log(`🎯 Created default challenges structure`);
|
||||
} else if (name === 'progress.json') {
|
||||
// Create empty progress structure for each user
|
||||
const users = readJsonSafe(path.join(DATA_DIR, files.users), []);
|
||||
const defaultProgress = {};
|
||||
|
||||
users.forEach(user => {
|
||||
defaultProgress[user.username] = {
|
||||
"stats": {
|
||||
"points": 0,
|
||||
"totalSessions": 0,
|
||||
"totalWords": 0,
|
||||
"correctWords": 0,
|
||||
"accuracy": 0
|
||||
},
|
||||
"streaks": {
|
||||
"current": 0,
|
||||
"longest": 0,
|
||||
"lastActivity": null
|
||||
},
|
||||
"challengesCompleted": {
|
||||
"daily": [],
|
||||
"weekly": [],
|
||||
"achievements": []
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
fs.writeFileSync(targetPath, JSON.stringify(defaultProgress, null, 2));
|
||||
console.log(`📈 Created default progress structure for ${Object.keys(defaultProgress).length} users`);
|
||||
} else if (name === 'spacedRepetition.json') {
|
||||
// Create empty spaced repetition structure for each user
|
||||
const users = readJsonSafe(path.join(DATA_DIR, files.users), []);
|
||||
const defaultSRData = {};
|
||||
|
||||
users.forEach(user => {
|
||||
defaultSRData[user.username] = {
|
||||
"settings": {
|
||||
"dailyLimit": 20,
|
||||
"reviewThreshold": 3,
|
||||
"newWordsPerDay": 5
|
||||
},
|
||||
"stats": {
|
||||
"totalReviews": 0,
|
||||
"correctReviews": 0,
|
||||
"totalWords": 0,
|
||||
"masteredWords": 0
|
||||
},
|
||||
"words": {}
|
||||
};
|
||||
});
|
||||
|
||||
fs.writeFileSync(targetPath, JSON.stringify(defaultSRData, null, 2));
|
||||
console.log(`📚 Created default spaced repetition structure for ${Object.keys(defaultSRData).length} users`);
|
||||
} else {
|
||||
fs.writeFileSync(targetPath, fallback);
|
||||
console.log(`📄 Initialized ${name} with default`);
|
||||
@@ -161,6 +366,55 @@ function initializeUserInAllFiles(username) {
|
||||
fs.writeFileSync(badgesPath, JSON.stringify(badges, null, 2));
|
||||
console.log(`🏆 Added badge tracking for "${username}"`);
|
||||
}
|
||||
|
||||
// Initialize in progress.json
|
||||
const progressPath = path.join(DATA_DIR, files.progress);
|
||||
const progress = readJsonSafe(progressPath, {});
|
||||
if (!progress[username]) {
|
||||
progress[username] = {
|
||||
stats: {
|
||||
points: 0,
|
||||
totalSessions: 0,
|
||||
totalWords: 0,
|
||||
correctWords: 0,
|
||||
accuracy: 0
|
||||
},
|
||||
streaks: {
|
||||
current: 0,
|
||||
longest: 0,
|
||||
lastActivity: null
|
||||
},
|
||||
challengesCompleted: {
|
||||
daily: [],
|
||||
weekly: [],
|
||||
achievements: []
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2));
|
||||
console.log(`📈 Added progress tracking for "${username}"`);
|
||||
}
|
||||
|
||||
// Initialize in spacedRepetition.json
|
||||
const srPath = path.join(DATA_DIR, files.spacedRepetition);
|
||||
const srData = readJsonSafe(srPath, {});
|
||||
if (!srData[username]) {
|
||||
srData[username] = {
|
||||
settings: {
|
||||
dailyLimit: 20,
|
||||
reviewThreshold: 3,
|
||||
newWordsPerDay: 5
|
||||
},
|
||||
stats: {
|
||||
totalReviews: 0,
|
||||
correctReviews: 0,
|
||||
totalWords: 0,
|
||||
masteredWords: 0
|
||||
},
|
||||
words: {}
|
||||
};
|
||||
fs.writeFileSync(srPath, JSON.stringify(srData, null, 2));
|
||||
console.log(`📚 Added spaced repetition data for "${username}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 Verify login
|
||||
@@ -384,6 +638,14 @@ app.get('/getResults', (req, res) => {
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
// Include gamification endpoints
|
||||
const gamificationEndpoints = fs.readFileSync(path.join(__dirname, 'gamification-endpoints.js'), 'utf8');
|
||||
eval(gamificationEndpoints);
|
||||
|
||||
// Include spaced repetition endpoints
|
||||
const spacedRepetitionEndpoints = fs.readFileSync(path.join(__dirname, 'spaced-repetition-endpoints.js'), 'utf8');
|
||||
eval(spacedRepetitionEndpoints);
|
||||
|
||||
// 📈 Enhanced Analytics API endpoint
|
||||
app.get('/getAnalytics', (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Spaced Repetition System API endpoints for the Spelling Practice App
|
||||
*/
|
||||
|
||||
// Get spaced repetition data
|
||||
app.get('/getSpacedRepetitionData', (req, res) => {
|
||||
try {
|
||||
const { username } = req.query;
|
||||
if (!username) {
|
||||
return res.status(400).json({ error: 'Username is required' });
|
||||
}
|
||||
|
||||
// Get spaced repetition data
|
||||
const srPath = path.join(DATA_DIR, files.spacedRepetition);
|
||||
const srData = readJsonSafe(srPath, {});
|
||||
|
||||
// If user doesn't have data yet, initialize them
|
||||
if (!srData[username]) {
|
||||
srData[username] = {
|
||||
settings: {
|
||||
dailyLimit: 20,
|
||||
reviewThreshold: 3,
|
||||
newWordsPerDay: 5
|
||||
},
|
||||
stats: {
|
||||
totalReviews: 0,
|
||||
correctReviews: 0,
|
||||
totalWords: 0,
|
||||
masteredWords: 0
|
||||
},
|
||||
words: {}
|
||||
};
|
||||
|
||||
// Save the new user data
|
||||
fs.writeFileSync(srPath, JSON.stringify(srData, null, 2));
|
||||
console.log(`📚 Initialized spaced repetition data for "${username}"`);
|
||||
}
|
||||
|
||||
res.json(srData[username]);
|
||||
} catch (error) {
|
||||
console.error('Error getting spaced repetition data:', error);
|
||||
res.status(500).json({ error: 'Failed to get spaced repetition data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get word lists for a user (for importing into spaced repetition)
|
||||
app.get('/getWordlistsForUser', (req, res) => {
|
||||
try {
|
||||
const { username } = req.query;
|
||||
if (!username) {
|
||||
return res.status(400).json({ error: 'Username is required' });
|
||||
}
|
||||
|
||||
// Get word lists
|
||||
const wordlistsPath = path.join(DATA_DIR, files.wordlists);
|
||||
const wordlists = readJsonSafe(wordlistsPath, {});
|
||||
|
||||
// If user doesn't have a word list, return an empty array
|
||||
if (!wordlists[username]) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
res.json(wordlists[username]);
|
||||
} catch (error) {
|
||||
console.error('Error getting wordlists for user:', error);
|
||||
res.status(500).json({ error: 'Failed to get wordlists' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update spaced repetition data
|
||||
app.post('/updateSpacedRepetitionData', (req, res) => {
|
||||
try {
|
||||
const { username, data } = req.body;
|
||||
if (!username || !data) {
|
||||
return res.status(400).json({ error: 'Username and data are required' });
|
||||
}
|
||||
|
||||
// Update data
|
||||
const srPath = path.join(DATA_DIR, files.spacedRepetition);
|
||||
const srData = readJsonSafe(srPath, {});
|
||||
|
||||
// Update user data
|
||||
srData[username] = data;
|
||||
|
||||
// Calculate stats
|
||||
const words = Object.values(data.words || {});
|
||||
data.stats = {
|
||||
totalReviews: words.reduce((sum, word) => sum + (word.repetitions || 0), 0),
|
||||
correctReviews: words.reduce((sum, word) => sum + ((word.repetitions > 0) ? word.repetitions - 1 : 0), 0),
|
||||
totalWords: words.length,
|
||||
masteredWords: words.filter(word => word.repetitions >= 5).length
|
||||
};
|
||||
|
||||
// Save updated data
|
||||
fs.writeFileSync(srPath, JSON.stringify(srData, null, 2));
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error updating spaced repetition data:', error);
|
||||
res.status(500).json({ error: 'Failed to update spaced repetition data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate a spaced repetition review session
|
||||
app.post('/generateSpacedRepetitionSession', (req, res) => {
|
||||
try {
|
||||
const { username, count = 10 } = req.body;
|
||||
if (!username) {
|
||||
return res.status(400).json({ error: 'Username is required' });
|
||||
}
|
||||
|
||||
// Get spaced repetition data
|
||||
const srPath = path.join(DATA_DIR, files.spacedRepetition);
|
||||
const srData = readJsonSafe(srPath, {});
|
||||
|
||||
// If user doesn't exist, return an error
|
||||
if (!srData[username]) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const userData = srData[username];
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Find words due for review
|
||||
const dueWords = Object.entries(userData.words || {})
|
||||
.filter(([_, wordData]) => wordData.nextReview && wordData.nextReview <= today)
|
||||
.map(([word, wordData]) => ({
|
||||
word,
|
||||
easeFactor: wordData.easeFactor,
|
||||
interval: wordData.interval,
|
||||
priority: calculatePriority(wordData, today)
|
||||
}))
|
||||
.sort((a, b) => b.priority - a.priority) // Sort by priority (highest first)
|
||||
.slice(0, count) // Take only the requested number of words
|
||||
.map(item => item.word);
|
||||
|
||||
res.json({ words: dueWords });
|
||||
} catch (error) {
|
||||
console.error('Error generating spaced repetition session:', error);
|
||||
res.status(500).json({ error: 'Failed to generate session' });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to calculate priority for a word
|
||||
function calculatePriority(wordData, today) {
|
||||
if (!wordData.nextReview) return 0;
|
||||
|
||||
const nextReview = new Date(wordData.nextReview);
|
||||
const todayDate = new Date(today);
|
||||
const daysOverdue = Math.max(0, (todayDate - 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);
|
||||
}
|
||||
|
||||
console.log('📚 Spaced repetition system endpoints initialized');
|
||||
Reference in new issue
Block a user