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
+22997
-21
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",
|
"name": "modern-spelling-practice",
|
||||||
"version": "2.1.0",
|
"version": "3.0.0",
|
||||||
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, and analytics",
|
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, analytics, gamification, and spaced repetition learning",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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="tab-contrast.css" rel="stylesheet"/>
|
||||||
<link href="badges.css" rel="stylesheet"/>
|
<link href="badges.css" rel="stylesheet"/>
|
||||||
<link href="analytics.css" rel="stylesheet"/>
|
<link href="analytics.css" rel="stylesheet"/>
|
||||||
|
<link href="gamification.css" rel="stylesheet"/>
|
||||||
|
<link href="spaced-repetition.css" rel="stylesheet"/>
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
|
||||||
<script src="js/auth.js"></script>
|
<script src="js/auth.js"></script>
|
||||||
@@ -22,6 +24,8 @@
|
|||||||
<script src="js/sessions.js"></script>
|
<script src="js/sessions.js"></script>
|
||||||
<script src="js/admin-badges.js"></script>
|
<script src="js/admin-badges.js"></script>
|
||||||
<script src="js/analytics.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>
|
<script src="js/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -459,6 +463,12 @@
|
|||||||
<p style="color: var(--text-secondary);">Choose an activity to start practicing</p>
|
<p style="color: var(--text-secondary);">Choose an activity to start practicing</p>
|
||||||
</div>
|
</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 -->
|
<!-- Theme Selection for Students -->
|
||||||
<div class="card" style="margin-bottom: 1.5rem;">
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
<h4>🎨 Choose Your Theme</h4>
|
<h4>🎨 Choose Your Theme</h4>
|
||||||
@@ -484,6 +494,46 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+54
-16
@@ -3,6 +3,7 @@ let words = [];
|
|||||||
let currentWord = '';
|
let currentWord = '';
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
let results = [];
|
let results = [];
|
||||||
|
let isSpacedRepetitionMode = false;
|
||||||
// 📝 Check user's answer and move to next
|
// 📝 Check user's answer and move to next
|
||||||
window.submitAnswer = function() {
|
window.submitAnswer = function() {
|
||||||
const input = document.getElementById('userInput').value.trim();
|
const input = document.getElementById('userInput').value.trim();
|
||||||
@@ -73,7 +74,7 @@ function showStudent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 Begin game session
|
// 🚀 Begin game session
|
||||||
window.startGame = async function() {
|
window.startGame = async function(customWords = null, isSpacedRepetition = false) {
|
||||||
console.log('🎮 Starting spelling game');
|
console.log('🎮 Starting spelling game');
|
||||||
|
|
||||||
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
||||||
@@ -95,29 +96,43 @@ window.startGame = async function() {
|
|||||||
document.getElementById('summary')?.classList.add('hidden');
|
document.getElementById('summary')?.classList.add('hidden');
|
||||||
document.getElementById('badgeDisplay')?.classList.add('hidden');
|
document.getElementById('badgeDisplay')?.classList.add('hidden');
|
||||||
|
|
||||||
// Fetch words
|
let wordList;
|
||||||
const response = await fetch(`/getWordList?username=${user.username}`);
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('Received words:', data);
|
|
||||||
|
|
||||||
let weekWords = [];
|
// Use custom words if provided (for spaced repetition)
|
||||||
// If weeks format and activeWeek is set, use only that week's words
|
if (customWords && Array.isArray(customWords) && customWords.length > 0) {
|
||||||
if (data.words && Array.isArray(data.words)) {
|
wordList = customWords;
|
||||||
weekWords = data.words;
|
isSpacedRepetitionMode = isSpacedRepetition;
|
||||||
} else if (data.words && data.words.weeks && data.words.activeWeek) {
|
console.log('Using custom word list for spaced repetition:', wordList);
|
||||||
const active = data.words.activeWeek;
|
} else {
|
||||||
const found = data.words.weeks.find(w => w.date === active);
|
// Fetch words from user's word list
|
||||||
if (found) weekWords = found.words;
|
const response = await fetch(`/getWordList?username=${user.username}`);
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Received words:', data);
|
||||||
|
|
||||||
|
let weekWords = [];
|
||||||
|
// If weeks format and activeWeek is set, use only that week's words
|
||||||
|
if (data.words && Array.isArray(data.words)) {
|
||||||
|
weekWords = data.words;
|
||||||
|
} else if (data.words && data.words.weeks && data.words.activeWeek) {
|
||||||
|
const active = data.words.activeWeek;
|
||||||
|
const found = data.words.weeks.find(w => w.date === active);
|
||||||
|
if (found) weekWords = found.words;
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
// If data is already an array
|
||||||
|
weekWords = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
wordList = weekWords;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!weekWords.length) {
|
if (!wordList.length) {
|
||||||
alert('No words found for practice');
|
alert('No words found for practice');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start game
|
// Start game
|
||||||
words = [...weekWords];
|
words = [...wordList];
|
||||||
typingWords = [...weekWords]; // Initialize typing words too
|
typingWords = [...wordList]; // Initialize typing words too
|
||||||
currentIndex = 0;
|
currentIndex = 0;
|
||||||
results = []; // Reset results array
|
results = []; // Reset results array
|
||||||
showWord();
|
showWord();
|
||||||
@@ -177,6 +192,7 @@ function showSummary() {
|
|||||||
let correct = 0;
|
let correct = 0;
|
||||||
let missed = [];
|
let missed = [];
|
||||||
|
|
||||||
|
// Process results to calculate correct answers and build a report
|
||||||
const report = results.map(({ word, attempt }) => {
|
const report = results.map(({ word, attempt }) => {
|
||||||
const isCorrect = word.toLowerCase() === attempt.toLowerCase();
|
const isCorrect = word.toLowerCase() === attempt.toLowerCase();
|
||||||
if (isCorrect) correct++;
|
if (isCorrect) correct++;
|
||||||
@@ -184,11 +200,33 @@ function showSummary() {
|
|||||||
return `<li>${word} — <strong style="color:${isCorrect ? 'green' : 'red'}">${attempt || '(no answer)'}</strong></li>`;
|
return `<li>${word} — <strong style="color:${isCorrect ? 'green' : 'red'}">${attempt || '(no answer)'}</strong></li>`;
|
||||||
}).join('');
|
}).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 = `
|
summary.innerHTML = `
|
||||||
<h2>Results</h2>
|
<h2>Results</h2>
|
||||||
<p>You got ${correct} out of ${words.length} correct (${Math.round((correct / words.length) * 100)}%)</p>
|
<p>You got ${correct} out of ${words.length} correct (${Math.round((correct / words.length) * 100)}%)</p>
|
||||||
<ul>${report}</ul>
|
<ul>${report}</ul>
|
||||||
${missed.length ? `<button onclick='retryMissed(${JSON.stringify(missed)})'>Retry Missed Words</button>` : ''}
|
${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');
|
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');
|
const SEED_DIR = path.join(__dirname, 'seed');
|
||||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
|
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
|
// 🗃 File mapping
|
||||||
const files = {
|
const files = {
|
||||||
users: 'users.json',
|
users: 'users.json',
|
||||||
wordlists: 'wordlists.json',
|
wordlists: 'wordlists.json',
|
||||||
results: 'results.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
|
// 🌱 Initialize data from seeds if missing/empty
|
||||||
@@ -82,6 +137,156 @@ function ensureFileWithSeed(name, fallback = '{}') {
|
|||||||
};
|
};
|
||||||
fs.writeFileSync(targetPath, JSON.stringify(defaultBadges, null, 2));
|
fs.writeFileSync(targetPath, JSON.stringify(defaultBadges, null, 2));
|
||||||
console.log(`🏆 Created default badge structure`);
|
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 {
|
} else {
|
||||||
fs.writeFileSync(targetPath, fallback);
|
fs.writeFileSync(targetPath, fallback);
|
||||||
console.log(`📄 Initialized ${name} with default`);
|
console.log(`📄 Initialized ${name} with default`);
|
||||||
@@ -161,6 +366,55 @@ function initializeUserInAllFiles(username) {
|
|||||||
fs.writeFileSync(badgesPath, JSON.stringify(badges, null, 2));
|
fs.writeFileSync(badgesPath, JSON.stringify(badges, null, 2));
|
||||||
console.log(`🏆 Added badge tracking for "${username}"`);
|
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
|
// 🔐 Verify login
|
||||||
@@ -384,6 +638,14 @@ app.get('/getResults', (req, res) => {
|
|||||||
res.json(results);
|
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
|
// 📈 Enhanced Analytics API endpoint
|
||||||
app.get('/getAnalytics', (req, res) => {
|
app.get('/getAnalytics', (req, res) => {
|
||||||
try {
|
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