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

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

No files matched your search

+89
View File
@@ -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": "🐝"
}
]
}
+511
View File
@@ -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"
}
+551
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+300
View File
@@ -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"
}
}
}
}
+470
View File
@@ -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);
}
}
+373
View File
@@ -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();
+285
View File
@@ -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();
+242
View File
@@ -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();
+222
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "modern-spelling-practice",
"version": "2.1.0",
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, and analytics",
"version": "3.0.0",
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, analytics, gamification, and spaced repetition learning",
"main": "server.js",
"scripts": {
"start": "node server.js",
+346
View File
@@ -0,0 +1,346 @@
/* Gamification CSS for Spelling Practice App */
/* Leaderboard styles */
.leaderboard-container {
margin-top: 1rem;
}
.leaderboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.leaderboard-tabs {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.leaderboard-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
cursor: pointer;
background-color: var(--bg-secondary);
transition: all 0.2s ease;
}
.leaderboard-tab.active {
background-color: var(--primary);
color: white;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
border-radius: var(--radius);
overflow: hidden;
}
.leaderboard-table th {
background-color: var(--bg-secondary);
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
}
.leaderboard-table tr:nth-child(even) {
background-color: var(--bg-secondary);
}
.leaderboard-table tr:hover {
background-color: var(--bg-hover);
}
.leaderboard-table td {
padding: 0.75rem 1rem;
}
.leaderboard-rank {
font-weight: 700;
text-align: center;
width: 3rem;
}
.leaderboard-value {
font-weight: 600;
text-align: right;
}
.leaderboard-date {
font-size: 0.85rem;
color: var(--text-secondary);
text-align: right;
}
/* Top ranks */
.leaderboard-table tr:nth-child(1) .leaderboard-rank {
color: gold;
}
.leaderboard-table tr:nth-child(2) .leaderboard-rank {
color: silver;
}
.leaderboard-table tr:nth-child(3) .leaderboard-rank {
color: #cd7f32; /* bronze */
}
/* Challenges styles */
.challenges-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.challenge-card {
background-color: var(--bg-secondary);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
position: relative;
overflow: hidden;
transition: all 0.2s ease;
}
.challenge-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow);
}
.challenge-card.daily {
border-left: 4px solid var(--primary);
}
.challenge-card.weekly {
border-left: 4px solid var(--accent);
}
.challenge-card.achievement {
border-left: 4px solid var(--success);
}
.challenge-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.challenge-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
}
.challenge-badge {
font-size: 0.9rem;
padding: 0.25rem 0.75rem;
border-radius: 99px;
background-color: var(--bg-primary);
}
.challenge-badge.daily {
background-color: var(--primary-soft);
color: var(--primary);
}
.challenge-badge.weekly {
background-color: var(--accent-soft);
color: var(--accent);
}
.challenge-badge.achievement {
background-color: var(--success-soft);
color: var(--success);
}
.challenge-description {
margin: 0.5rem 0;
color: var(--text-secondary);
}
.challenge-progress {
margin-top: 1rem;
}
.progress-bar-container {
width: 100%;
height: 0.5rem;
background-color: var(--bg-primary);
border-radius: var(--radius);
overflow: hidden;
margin: 0.5rem 0;
}
.progress-bar-fill {
height: 100%;
background-color: var(--primary);
transition: width 0.3s ease;
}
.progress-bar-fill.daily {
background-color: var(--primary);
}
.progress-bar-fill.weekly {
background-color: var(--accent);
}
.progress-bar-fill.achievement {
background-color: var(--success);
}
.challenge-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.challenge-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.challenge-reward {
display: flex;
align-items: center;
gap: 0.5rem;
}
.challenge-points {
font-weight: 600;
color: var(--primary);
}
.challenge-complete-btn {
padding: 0.5rem 1rem;
background-color: var(--primary);
color: white;
border: none;
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.challenge-complete-btn:hover {
background-color: var(--primary-hover);
transform: translateY(-2px);
}
.challenge-complete-btn:active {
transform: translateY(0);
}
.challenge-complete-btn.completed {
background-color: var(--success);
pointer-events: none;
}
.challenge-complete-btn.locked {
background-color: var(--bg-primary);
color: var(--text-secondary);
cursor: not-allowed;
}
/* User profile stats */
.user-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background-color: var(--bg-secondary);
border-radius: var(--radius);
padding: 1.25rem;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
margin: 0.25rem 0;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Streak indicator */
.streak-container {
display: flex;
align-items: center;
gap: 1rem;
background-color: var(--bg-secondary);
border-radius: var(--radius);
padding: 0.75rem 1.25rem;
margin-bottom: 1.5rem;
}
.streak-flame {
font-size: 1.5rem;
}
.streak-info {
flex: 1;
}
.streak-days {
font-weight: 600;
font-size: 1.1rem;
}
.streak-subtitle {
font-size: 0.85rem;
color: var(--text-secondary);
}
.streak-progress {
display: flex;
gap: 0.35rem;
margin-top: 0.5rem;
}
.streak-day {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
background-color: var(--bg-primary);
}
.streak-day.active {
background-color: var(--primary);
}
/* Achievements grid */
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.user-stats {
grid-template-columns: repeat(2, 1fr);
}
.challenge-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.challenge-footer {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
+50
View File
@@ -9,6 +9,8 @@
<link href="tab-contrast.css" rel="stylesheet"/>
<link href="badges.css" rel="stylesheet"/>
<link href="analytics.css" rel="stylesheet"/>
<link href="gamification.css" rel="stylesheet"/>
<link href="spaced-repetition.css" rel="stylesheet"/>
<!-- Scripts -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
<script src="js/auth.js"></script>
@@ -22,6 +24,8 @@
<script src="js/sessions.js"></script>
<script src="js/admin-badges.js"></script>
<script src="js/analytics.js"></script>
<script src="js/gamification.js"></script>
<script src="js/spaced-repetition.js"></script>
<script src="js/main.js"></script>
</head>
<body>
@@ -459,6 +463,12 @@
<p style="color: var(--text-secondary);">Choose an activity to start practicing</p>
</div>
<!-- Streak & User Stats Section -->
<div class="dashboard-section">
<div id="streakContainer" class="card dashboard-card"></div>
<div id="userStatsContainer" class="card dashboard-card"></div>
</div>
<!-- Theme Selection for Students -->
<div class="card" style="margin-bottom: 1.5rem;">
<h4>🎨 Choose Your Theme</h4>
@@ -484,6 +494,46 @@
</button>
</div>
</div>
<!-- Challenges Section -->
<div class="card dashboard-section" style="margin-top: 1.5rem;">
<div class="section-header">
<h3>🎯 Active Challenges</h3>
</div>
<div id="challengesContainer" class="challenges-section"></div>
</div>
<!-- Leaderboards Section -->
<div class="card dashboard-section" style="margin-top: 1.5rem;">
<div class="section-header">
<h3>🏆 Leaderboards</h3>
</div>
<div id="leaderboardsContainer" class="leaderboards-section"></div>
</div>
<!-- Spaced Repetition Section -->
<div class="card dashboard-section" style="margin-top: 1.5rem;">
<div class="section-header">
<h3>📚 Spaced Repetition</h3>
</div>
<div class="sr-info">
<h4>How Spaced Repetition Works</h4>
<p>Words you find difficult will appear more frequently, while words you know well will appear less often. This helps you learn more efficiently!</p>
</div>
<!-- SR Stats -->
<div id="srStatsContainer" class="sr-stats-container"></div>
<!-- SR Actions -->
<div class="sr-actions">
<button id="startSpacedReviewBtn" class="btn-primary" onclick="startSpacedReview()">🔄 Start Review</button>
<button class="btn-secondary" onclick="importWordsToSR()">📥 Import Words</button>
</div>
<!-- SR Progress -->
<div id="srProgressContainer" class="sr-progress-container"></div>
</div>
</div>
</div>
+54 -16
View File
@@ -3,6 +3,7 @@ let words = [];
let currentWord = '';
let currentIndex = 0;
let results = [];
let isSpacedRepetitionMode = false;
// 📝 Check user's answer and move to next
window.submitAnswer = function() {
const input = document.getElementById('userInput').value.trim();
@@ -73,7 +74,7 @@ function showStudent() {
}
// 🚀 Begin game session
window.startGame = async function() {
window.startGame = async function(customWords = null, isSpacedRepetition = false) {
console.log('🎮 Starting spelling game');
const user = JSON.parse(localStorage.getItem('loggedInUser'));
@@ -95,29 +96,43 @@ window.startGame = async function() {
document.getElementById('summary')?.classList.add('hidden');
document.getElementById('badgeDisplay')?.classList.add('hidden');
// Fetch words
const response = await fetch(`/getWordList?username=${user.username}`);
const data = await response.json();
console.log('Received words:', data);
let wordList;
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;
// Use custom words if provided (for spaced repetition)
if (customWords && Array.isArray(customWords) && customWords.length > 0) {
wordList = customWords;
isSpacedRepetitionMode = isSpacedRepetition;
console.log('Using custom word list for spaced repetition:', wordList);
} else {
// Fetch words from user's word list
const response = await fetch(`/getWordList?username=${user.username}`);
const data = await response.json();
console.log('Received words:', data);
let weekWords = [];
// If weeks format and activeWeek is set, use only that week's words
if (data.words && Array.isArray(data.words)) {
weekWords = data.words;
} else if (data.words && data.words.weeks && data.words.activeWeek) {
const active = data.words.activeWeek;
const found = data.words.weeks.find(w => w.date === active);
if (found) weekWords = found.words;
} else if (Array.isArray(data)) {
// If data is already an array
weekWords = data;
}
wordList = weekWords;
}
if (!weekWords.length) {
if (!wordList.length) {
alert('No words found for practice');
return;
}
// Start game
words = [...weekWords];
typingWords = [...weekWords]; // Initialize typing words too
words = [...wordList];
typingWords = [...wordList]; // Initialize typing words too
currentIndex = 0;
results = []; // Reset results array
showWord();
@@ -177,6 +192,7 @@ function showSummary() {
let correct = 0;
let missed = [];
// Process results to calculate correct answers and build a report
const report = results.map(({ word, attempt }) => {
const isCorrect = word.toLowerCase() === attempt.toLowerCase();
if (isCorrect) correct++;
@@ -184,11 +200,33 @@ function showSummary() {
return `<li>${word} — <strong style="color:${isCorrect ? 'green' : 'red'}">${attempt || '(no answer)'}</strong></li>`;
}).join('');
// Create detailed results for spaced repetition processing
const detailedResults = results.map(({ word, attempt }) => {
return {
word,
correct: word.toLowerCase() === attempt.toLowerCase(),
attempt
};
});
// Dispatch event for spaced repetition system if in SR mode
if (isSpacedRepetitionMode) {
const event = new CustomEvent('gameResultsAvailable', {
detail: {
isSpacedRepetition: true,
results: detailedResults
}
});
document.dispatchEvent(event);
}
// Build and display summary
summary.innerHTML = `
<h2>Results</h2>
<p>You got ${correct} out of ${words.length} correct (${Math.round((correct / words.length) * 100)}%)</p>
<ul>${report}</ul>
${missed.length ? `<button onclick='retryMissed(${JSON.stringify(missed)})'>Retry Missed Words</button>` : ''}
${isSpacedRepetitionMode ? `<button onclick='backToStudentDashboard()' class="btn-primary">Back to Dashboard</button>` : ''}
`;
summary.classList.remove('hidden');
+501
View File
@@ -0,0 +1,501 @@
/**
* Gamification features for Spelling Practice App
* - Leaderboards
* - Challenges & Achievements
* - Streaks
* - Points system
*/
// Global variables for gamification
let userProgress = null;
let challenges = null;
let leaderboards = null;
let currentUser = null;
// Initialize gamification features
document.addEventListener('DOMContentLoaded', function() {
// Event listener will be triggered when a user logs in
document.addEventListener('userLoggedIn', function(e) {
currentUser = e.detail.username;
initGamification(currentUser);
});
});
// Main initialization function for gamification
async function initGamification(username) {
try {
// Fetch all required data
await Promise.all([
fetchUserProgress(username),
fetchChallenges(),
fetchLeaderboards()
]);
// Update streak (marks user as active today)
await updateUserStreak(username);
// Render all gamification components
renderStreakIndicator();
renderUserStats();
renderChallenges();
renderLeaderboards();
console.log('🎮 Gamification features initialized');
} catch (error) {
console.error('Error initializing gamification:', error);
}
}
// Fetch user progress data
async function fetchUserProgress(username) {
try {
const response = await fetch(`/getUserProgress?username=${encodeURIComponent(username)}`);
if (!response.ok) {
throw new Error(`Failed to fetch user progress: ${response.status}`);
}
userProgress = await response.json();
return userProgress;
} catch (error) {
console.error('Error fetching user progress:', error);
return null;
}
}
// Fetch challenges data
async function fetchChallenges() {
try {
const response = await fetch('/getChallenges');
if (!response.ok) {
throw new Error(`Failed to fetch challenges: ${response.status}`);
}
challenges = await response.json();
return challenges;
} catch (error) {
console.error('Error fetching challenges:', error);
return null;
}
}
// Fetch leaderboards data
async function fetchLeaderboards() {
try {
const response = await fetch('/getLeaderboards');
if (!response.ok) {
throw new Error(`Failed to fetch leaderboards: ${response.status}`);
}
leaderboards = await response.json();
return leaderboards;
} catch (error) {
console.error('Error fetching leaderboards:', error);
return null;
}
}
// Update user streak
async function updateUserStreak(username) {
try {
const response = await fetch('/updateStreak', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username })
});
if (!response.ok) {
throw new Error(`Failed to update streak: ${response.status}`);
}
const result = await response.json();
// Update local data
if (userProgress) {
userProgress.streaks = result.streak;
}
return result;
} catch (error) {
console.error('Error updating streak:', error);
return null;
}
}
// Complete a challenge
async function completeChallenge(challengeId, challengeType) {
if (!currentUser) {
console.error('No user logged in');
return;
}
try {
const response = await fetch('/completeChallenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: currentUser,
challengeId,
challengeType
})
});
if (!response.ok) {
throw new Error(`Failed to complete challenge: ${response.status}`);
}
const result = await response.json();
// Update local data
userProgress = result.updatedProgress;
// Update UI
renderUserStats();
renderChallenges();
// Show success message
showToast('Challenge completed!', 'success');
return result;
} catch (error) {
console.error('Error completing challenge:', error);
showToast('Failed to complete challenge', 'error');
return null;
}
}
// Render streak indicator in student panel
function renderStreakIndicator() {
const streakContainer = document.getElementById('streakContainer');
if (!streakContainer || !userProgress) return;
// Clear previous content
streakContainer.innerHTML = '';
const currentStreak = userProgress.streaks.current || 0;
// Create streak element
const streakEl = document.createElement('div');
streakEl.className = 'streak-container';
streakEl.innerHTML = `
<div class="streak-flame">🔥</div>
<div class="streak-info">
<div class="streak-days">${currentStreak} Day${currentStreak === 1 ? '' : 's'}</div>
<div class="streak-subtitle">${currentStreak > 0 ? 'Current streak' : 'Start your streak today!'}</div>
<div class="streak-progress">
${Array.from({ length: 7 }, (_, i) =>
`<div class="streak-day${i < currentStreak ? ' active' : ''}"></div>`
).join('')}
</div>
</div>
<div class="streak-record">
<div class="streak-subtitle">Best</div>
<div class="streak-best">${userProgress.streaks.longest || 0}</div>
</div>
`;
streakContainer.appendChild(streakEl);
}
// Render user stats in student panel
function renderUserStats() {
const statsContainer = document.getElementById('userStatsContainer');
if (!statsContainer || !userProgress) return;
// Clear previous content
statsContainer.innerHTML = '';
// Create stats container
const statsEl = document.createElement('div');
statsEl.className = 'user-stats';
// Add stat cards
statsEl.innerHTML = `
<div class="stat-card">
<div class="stat-value">${userProgress.stats.points || 0}</div>
<div class="stat-label">Points</div>
</div>
<div class="stat-card">
<div class="stat-value">${userProgress.stats.accuracy ? userProgress.stats.accuracy.toFixed(1) + '%' : '0%'}</div>
<div class="stat-label">Accuracy</div>
</div>
<div class="stat-card">
<div class="stat-value">${userProgress.stats.totalSessions || 0}</div>
<div class="stat-label">Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">${userProgress.stats.totalWords || 0}</div>
<div class="stat-label">Words</div>
</div>
`;
statsContainer.appendChild(statsEl);
}
// Render challenges in student panel
function renderChallenges() {
const challengesContainer = document.getElementById('challengesContainer');
if (!challengesContainer || !challenges || !userProgress) return;
// Clear previous content
challengesContainer.innerHTML = '';
// Create challenges container
const challengesEl = document.createElement('div');
challengesEl.className = 'challenges-container';
// Daily challenge
if (challenges.daily && challenges.daily.current) {
const dailyChallenge = challenges.daily.current;
const isCompleted = userProgress.challengesCompleted.daily.includes(dailyChallenge.id);
challengesEl.appendChild(createChallengeCard(dailyChallenge, 'daily', isCompleted));
}
// Weekly challenge
if (challenges.weekly && challenges.weekly.current) {
const weeklyChallenge = challenges.weekly.current;
const isCompleted = userProgress.challengesCompleted.weekly.includes(weeklyChallenge.id);
challengesEl.appendChild(createChallengeCard(weeklyChallenge, 'weekly', isCompleted));
}
// Achievement challenges (show only 3 unearned ones)
if (challenges.achievements && challenges.achievements.length > 0) {
const uncompletedAchievements = challenges.achievements
.filter(achievement => !userProgress.challengesCompleted.achievements.includes(achievement.id))
.slice(0, 3);
uncompletedAchievements.forEach(achievement => {
challengesEl.appendChild(createChallengeCard(achievement, 'achievement', false));
});
}
challengesContainer.appendChild(challengesEl);
}
// Create a challenge card element
function createChallengeCard(challenge, type, isCompleted) {
const card = document.createElement('div');
card.className = `challenge-card ${type}`;
card.dataset.id = challenge.id;
// Calculate progress for the challenge
let progress = 0;
let progressText = '';
switch (challenge.type) {
case 'accuracy':
progress = userProgress.stats.accuracy ? Math.min(userProgress.stats.accuracy / challenge.target, 1) : 0;
progressText = `${userProgress.stats.accuracy ? userProgress.stats.accuracy.toFixed(1) : 0}% / ${challenge.target}%`;
break;
case 'words':
case 'correctWords':
progress = Math.min(userProgress.stats.correctWords / challenge.target, 1);
progressText = `${userProgress.stats.correctWords || 0} / ${challenge.target}`;
break;
case 'sessions':
progress = Math.min(userProgress.stats.totalSessions / challenge.target, 1);
progressText = `${userProgress.stats.totalSessions || 0} / ${challenge.target}`;
break;
case 'streak':
progress = Math.min(userProgress.streaks.current / challenge.target, 1);
progressText = `${userProgress.streaks.current || 0} / ${challenge.target}`;
break;
case 'perfectSessions':
// Would need to track this separately
progress = 0;
progressText = `0 / ${challenge.target}`;
break;
}
// Format date for challenge time period
let timeframe = '';
if (type === 'daily') {
timeframe = `Today (${new Date(challenge.date).toLocaleDateString()})`;
} else if (type === 'weekly') {
timeframe = `${new Date(challenge.startDate).toLocaleDateString()} - ${new Date(challenge.endDate).toLocaleDateString()}`;
}
card.innerHTML = `
<div class="challenge-header">
<h3 class="challenge-title">${challenge.title}</h3>
<span class="challenge-badge ${type}">${
type === 'daily' ? 'Daily'
: type === 'weekly' ? 'Weekly'
: 'Achievement'
}</span>
</div>
<div class="challenge-description">${challenge.description}</div>
<div class="challenge-progress">
<div class="progress-bar-container">
<div class="progress-bar-fill ${type}" style="width: ${Math.round(progress * 100)}%"></div>
</div>
<div style="display: flex; justify-content: space-between; font-size: 0.85rem;">
<span>${progressText}</span>
<span>${Math.round(progress * 100)}%</span>
</div>
</div>
<div class="challenge-footer">
<div class="challenge-meta">
${timeframe ? `<div>${timeframe}</div>` : ''}
<div class="challenge-reward">
<span>Reward:</span>
<span class="challenge-points">+${challenge.reward.points} pts</span>
${challenge.reward.badge ? `<span class="badge-icon">🏅 ${challenge.reward.badge}</span>` : ''}
</div>
</div>
<button
class="challenge-complete-btn${isCompleted ? ' completed' : progress >= 1 ? '' : ' locked'}"
${isCompleted ? 'disabled' : ''}
onclick="${isCompleted ? '' : progress >= 1 ? `completeChallenge('${challenge.id}', '${type === 'achievement' ? 'achievements' : type}')` : ''}"
>
${isCompleted ? 'Completed ✓' : progress >= 1 ? 'Claim Reward' : 'In Progress...'}
</button>
</div>
`;
return card;
}
// Render leaderboards in student panel
function renderLeaderboards() {
const leaderboardsContainer = document.getElementById('leaderboardsContainer');
if (!leaderboardsContainer || !leaderboards) return;
// Clear previous content
leaderboardsContainer.innerHTML = '';
// Create leaderboard container
const leaderboardEl = document.createElement('div');
leaderboardEl.className = 'leaderboard-container';
// Create header with tabs
const header = document.createElement('div');
header.className = 'leaderboard-header';
header.innerHTML = `
<h3>Leaderboards</h3>
<div class="leaderboard-tabs">
<div class="leaderboard-tab active" data-category="accuracy">Accuracy</div>
<div class="leaderboard-tab" data-category="totalWords">Words</div>
<div class="leaderboard-tab" data-category="streakDays">Streaks</div>
</div>
`;
leaderboardEl.appendChild(header);
// Create leaderboard table container
const tableContainer = document.createElement('div');
tableContainer.id = 'leaderboardTableContainer';
// Initially show accuracy leaderboard
tableContainer.appendChild(createLeaderboardTable('accuracy'));
leaderboardEl.appendChild(tableContainer);
leaderboardsContainer.appendChild(leaderboardEl);
// Add event listeners for tabs
const tabs = header.querySelectorAll('.leaderboard-tab');
tabs.forEach(tab => {
tab.addEventListener('click', function() {
// Remove active class from all tabs
tabs.forEach(t => t.classList.remove('active'));
// Add active class to clicked tab
this.classList.add('active');
// Update table
const category = this.dataset.category;
tableContainer.innerHTML = '';
tableContainer.appendChild(createLeaderboardTable(category));
});
});
}
// Create a leaderboard table for a specific category
function createLeaderboardTable(category) {
const table = document.createElement('table');
table.className = 'leaderboard-table';
// Create table header
const thead = document.createElement('thead');
thead.innerHTML = `
<tr>
<th class="leaderboard-rank">#</th>
<th>User</th>
<th class="leaderboard-value">${
category === 'accuracy' ? 'Accuracy'
: category === 'totalWords' ? 'Words'
: category === 'sessionsCompleted' ? 'Sessions'
: 'Streak Days'
}</th>
</tr>
`;
table.appendChild(thead);
// Create table body
const tbody = document.createElement('tbody');
// Get leaderboard data
const data = leaderboards.allTime[category] || [];
if (data.length === 0) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="3" style="text-align: center;">No data available</td>';
tbody.appendChild(tr);
} else {
data.forEach((entry, index) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="leaderboard-rank">${index + 1}</td>
<td>${entry.username}${entry.username === currentUser ? ' (You)' : ''}</td>
<td class="leaderboard-value">${
category === 'accuracy' ? entry.value.toFixed(1) + '%'
: entry.value
}</td>
`;
// Highlight current user
if (entry.username === currentUser) {
tr.style.backgroundColor = 'var(--primary-soft)';
}
tbody.appendChild(tr);
});
}
table.appendChild(tbody);
return table;
}
// Helper function to show toast messages
function showToast(message, type = 'info') {
// Check if toast container exists
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
document.body.appendChild(toastContainer);
}
// Create toast
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
// Add to container
toastContainer.appendChild(toast);
// Show toast with animation
setTimeout(() => toast.classList.add('show'), 10);
// Remove after delay
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Expose functions to window for use in HTML
window.completeChallenge = completeChallenge;
+391
View File
@@ -0,0 +1,391 @@
/**
* Spaced Repetition System for Spelling Practice App
*
* Based on the SM-2 algorithm (SuperMemo) with adaptations for spelling practice
* This system schedules words for review based on user performance
* Words are assigned difficulty levels and review intervals are adjusted accordingly
*/
// Global variables
let spacedRepetitionData = null;
let currentUser = null;
let currentWords = [];
let reviewQueue = [];
// Initialize the spaced repetition system
document.addEventListener('DOMContentLoaded', function() {
// Event listener will be triggered when a user logs in
document.addEventListener('userLoggedIn', function(e) {
currentUser = e.detail.username;
initializeSpacedRepetition(currentUser);
});
});
// Main initialization function
async function initializeSpacedRepetition(username) {
try {
await fetchSpacedRepetitionData(username);
updateSpacedRepetitionUI();
console.log('🔄 Spaced repetition system initialized');
} catch (error) {
console.error('Error initializing spaced repetition:', error);
}
}
// Fetch spaced repetition data for the current user
async function fetchSpacedRepetitionData(username) {
try {
const response = await fetch(`/getSpacedRepetitionData?username=${encodeURIComponent(username)}`);
if (!response.ok) {
throw new Error(`Failed to fetch spaced repetition data: ${response.status}`);
}
spacedRepetitionData = await response.json();
// If there are words due for review today, add them to the review queue
buildReviewQueue();
return spacedRepetitionData;
} catch (error) {
console.error('Error fetching spaced repetition data:', error);
return null;
}
}
// Build a queue of words that are due for review
function buildReviewQueue() {
if (!spacedRepetitionData || !spacedRepetitionData.words) {
reviewQueue = [];
return;
}
const today = new Date().toISOString().split('T')[0];
reviewQueue = Object.entries(spacedRepetitionData.words)
.filter(([_, wordData]) => {
return wordData.nextReview && wordData.nextReview <= today;
})
.map(([word, wordData]) => ({
word,
easeFactor: wordData.easeFactor,
interval: wordData.interval,
repetitions: wordData.repetitions,
lastReview: wordData.lastReview,
nextReview: wordData.nextReview,
priority: calculatePriority(wordData)
}))
.sort((a, b) => b.priority - a.priority); // Sort by priority (highest first)
}
// Calculate priority for a word based on how overdue it is and its difficulty
function calculatePriority(wordData) {
if (!wordData.nextReview) return 0;
const today = new Date();
const nextReview = new Date(wordData.nextReview);
const daysOverdue = Math.max(0, (today - nextReview) / (1000 * 60 * 60 * 24));
// Priority increases with days overdue and decreases with ease factor
// This prioritizes difficult, overdue words
return daysOverdue * (5 - wordData.easeFactor);
}
// Update the UI to show spaced repetition information
function updateSpacedRepetitionUI() {
const srStatsContainer = document.getElementById('srStatsContainer');
if (!srStatsContainer || !spacedRepetitionData) return;
// Clear previous content
srStatsContainer.innerHTML = '';
// Create stats container
const statsDiv = document.createElement('div');
statsDiv.className = 'sr-stats';
const todayCount = reviewQueue.length;
const masteredCount = Object.values(spacedRepetitionData.words || {})
.filter(word => word.easeFactor >= 2.5 && word.repetitions >= 5).length;
const totalCount = Object.keys(spacedRepetitionData.words || {}).length;
statsDiv.innerHTML = `
<div class="sr-stat-card">
<div class="sr-stat-value">${todayCount}</div>
<div class="sr-stat-label">Due Today</div>
</div>
<div class="sr-stat-card">
<div class="sr-stat-value">${masteredCount}</div>
<div class="sr-stat-label">Mastered</div>
</div>
<div class="sr-stat-card">
<div class="sr-stat-value">${totalCount}</div>
<div class="sr-stat-label">Total Words</div>
</div>
`;
srStatsContainer.appendChild(statsDiv);
// Update review button state
const reviewButton = document.getElementById('startSpacedReviewBtn');
if (reviewButton) {
if (todayCount > 0) {
reviewButton.classList.remove('disabled');
reviewButton.innerHTML = `🔄 Start Review (${todayCount})`;
} else {
reviewButton.classList.add('disabled');
reviewButton.innerHTML = '✓ All Caught Up';
}
}
// Update progress visualization
updateProgressVisualization();
}
// Update the progress visualization
function updateProgressVisualization() {
const progressContainer = document.getElementById('srProgressContainer');
if (!progressContainer || !spacedRepetitionData || !spacedRepetitionData.words) return;
// Clear previous content
progressContainer.innerHTML = '';
const words = Object.entries(spacedRepetitionData.words);
if (words.length === 0) {
progressContainer.innerHTML = '<p>No words in your spaced repetition system yet.</p>';
return;
}
// Create progress visualization
const progressDiv = document.createElement('div');
progressDiv.className = 'sr-progress-visualization';
// Group words by mastery level
const levels = {
'New': words.filter(([_, w]) => w.repetitions === 0).length,
'Learning': words.filter(([_, w]) => w.repetitions > 0 && w.repetitions < 3).length,
'Reviewing': words.filter(([_, w]) => w.repetitions >= 3 && w.repetitions < 5).length,
'Mastered': words.filter(([_, w]) => w.repetitions >= 5).length
};
const totalWords = Object.values(levels).reduce((sum, count) => sum + count, 0);
// Create progress bar
const progressBar = document.createElement('div');
progressBar.className = 'sr-progress-bar';
const colors = {
'New': '#ff6b6b',
'Learning': '#feca57',
'Reviewing': '#54a0ff',
'Mastered': '#1dd1a1'
};
Object.entries(levels).forEach(([level, count]) => {
if (count === 0) return;
const percentage = (count / totalWords) * 100;
const segment = document.createElement('div');
segment.className = 'sr-progress-segment';
segment.style.width = `${percentage}%`;
segment.style.backgroundColor = colors[level];
segment.title = `${level}: ${count} words (${percentage.toFixed(1)}%)`;
progressBar.appendChild(segment);
});
// Create legend
const legend = document.createElement('div');
legend.className = 'sr-legend';
Object.entries(levels).forEach(([level, count]) => {
const legendItem = document.createElement('div');
legendItem.className = 'sr-legend-item';
legendItem.innerHTML = `
<span class="sr-legend-color" style="background-color: ${colors[level]}"></span>
<span class="sr-legend-label">${level}: ${count}</span>
`;
legend.appendChild(legendItem);
});
progressDiv.appendChild(progressBar);
progressDiv.appendChild(legend);
progressContainer.appendChild(progressDiv);
}
// Start a spaced repetition review session
async function startSpacedReview() {
if (reviewQueue.length === 0) {
showToast('No words to review right now!', 'info');
return;
}
// Hide student panel and show game section
document.getElementById('studentPanel').classList.add('hidden');
document.getElementById('gameSection').classList.remove('hidden');
// Update UI to indicate this is a spaced repetition session
document.querySelector('#gameSection h3').textContent = '🔄 Spaced Repetition Review';
// Prepare words for the session (take up to 10 words from the queue)
currentWords = reviewQueue.slice(0, 10).map(item => item.word);
// Start the game with these words
await startGame(currentWords, true);
}
// Process results from a spaced repetition session
function processSpacedRepetitionResults(results) {
if (!currentUser || !spacedRepetitionData || !results || results.length === 0) return;
const now = new Date();
const today = now.toISOString().split('T')[0];
// Process each word from the results
results.forEach(result => {
const word = result.word;
const correct = result.correct;
// If word doesn't exist in the system yet, initialize it
if (!spacedRepetitionData.words[word]) {
spacedRepetitionData.words[word] = {
easeFactor: 2.5, // Initial ease factor
interval: 0, // Initial interval (0 = first time)
repetitions: 0, // Number of successful reviews
lastReview: null,
nextReview: null
};
}
const wordData = spacedRepetitionData.words[word];
// Apply SM-2 algorithm
if (correct) {
// Update based on correct answer
if (wordData.repetitions === 0) {
// First successful review
wordData.interval = 1;
} else if (wordData.repetitions === 1) {
// Second successful review
wordData.interval = 6;
} else {
// Subsequent successful reviews - increase interval
wordData.interval = Math.round(wordData.interval * wordData.easeFactor);
}
// Increment repetitions counter
wordData.repetitions += 1;
// Adjust ease factor (make it slightly easier if correct)
wordData.easeFactor = Math.max(1.3, wordData.easeFactor + 0.1);
} else {
// Reset on incorrect answer
wordData.repetitions = 0;
wordData.interval = 1;
// Make it harder if incorrect
wordData.easeFactor = Math.max(1.3, wordData.easeFactor - 0.2);
}
// Update review dates
wordData.lastReview = today;
// Calculate next review date
const nextDate = new Date(now);
nextDate.setDate(nextDate.getDate() + wordData.interval);
wordData.nextReview = nextDate.toISOString().split('T')[0];
});
// Save updated data to the server
saveSpacedRepetitionData();
// Update the queue and UI
buildReviewQueue();
updateSpacedRepetitionUI();
}
// Save the updated spaced repetition data to the server
async function saveSpacedRepetitionData() {
if (!currentUser || !spacedRepetitionData) return;
try {
const response = await fetch('/updateSpacedRepetitionData', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: currentUser,
data: spacedRepetitionData
})
});
if (!response.ok) {
throw new Error(`Failed to update spaced repetition data: ${response.status}`);
}
return true;
} catch (error) {
console.error('Error saving spaced repetition data:', error);
return false;
}
}
// Import new words to the spaced repetition system from a word list
async function importWordsToSR() {
if (!currentUser || !spacedRepetitionData) return;
try {
// Get user's word lists
const response = await fetch(`/getWordlistsForUser?username=${encodeURIComponent(currentUser)}`);
if (!response.ok) {
throw new Error(`Failed to fetch wordlists: ${response.status}`);
}
const wordlists = await response.json();
if (!wordlists || !Array.isArray(wordlists) || wordlists.length === 0) {
showToast('No word lists found', 'error');
return;
}
// For simplicity, let's import all words from the user's word list
const allWords = [...new Set(wordlists)]; // Remove duplicates
// Add each word to the spaced repetition system if not already present
let newWordsCount = 0;
allWords.forEach(word => {
if (!spacedRepetitionData.words[word]) {
spacedRepetitionData.words[word] = {
easeFactor: 2.5,
interval: 0,
repetitions: 0,
lastReview: null,
nextReview: new Date().toISOString().split('T')[0] // Due immediately
};
newWordsCount++;
}
});
// Save the updated data
await saveSpacedRepetitionData();
// Update UI
buildReviewQueue();
updateSpacedRepetitionUI();
showToast(`Added ${newWordsCount} new words to your spaced repetition system`, 'success');
} catch (error) {
console.error('Error importing words:', error);
showToast('Failed to import words', 'error');
}
}
// Export functions to window for use in HTML
window.startSpacedReview = startSpacedReview;
window.importWordsToSR = importWordsToSR;
// Register a handler for when game results are available
document.addEventListener('gameResultsAvailable', function(e) {
// Only process if this was a spaced repetition session
if (e.detail.isSpacedRepetition) {
processSpacedRepetitionResults(e.detail.results);
}
});
+233
View File
@@ -0,0 +1,233 @@
/* Spaced Repetition System Styles */
.sr-container {
margin-top: 1.5rem;
}
.sr-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.sr-stat-card {
background-color: var(--card-bg);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
transition: transform 0.2s ease;
}
.sr-stat-card:hover {
transform: translateY(-3px);
}
.sr-stat-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 0.3rem;
}
.sr-stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.sr-actions {
display: flex;
gap: 1rem;
margin: 1.5rem 0;
justify-content: center;
}
.sr-actions button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.sr-actions button.disabled {
opacity: 0.7;
cursor: not-allowed;
}
.sr-progress-visualization {
margin-top: 1.5rem;
}
.sr-progress-bar {
height: 24px;
border-radius: 12px;
overflow: hidden;
display: flex;
width: 100%;
background-color: var(--card-bg);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.sr-progress-segment {
height: 100%;
transition: width 0.3s ease;
}
.sr-legend {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
margin-top: 0.75rem;
}
.sr-legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.sr-legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
display: inline-block;
}
.sr-legend-label {
color: var(--text-secondary);
}
.sr-calendar {
margin-top: 2rem;
}
.sr-calendar-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 1rem;
text-align: center;
}
.sr-calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
}
.sr-calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.sr-calendar-header-cell {
text-align: center;
font-weight: 600;
font-size: 0.8rem;
color: var(--text-secondary);
}
.sr-calendar-day {
aspect-ratio: 1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
position: relative;
background-color: var(--card-bg);
cursor: default;
}
.sr-calendar-day.has-reviews {
background-color: var(--primary-soft);
color: var(--primary);
font-weight: 600;
}
.sr-calendar-day.today {
border: 2px solid var(--primary);
color: var(--primary);
font-weight: 700;
}
.sr-calendar-day .count {
position: absolute;
top: 2px;
right: 2px;
font-size: 0.7rem;
background-color: var(--primary);
color: white;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.sr-no-words {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.sr-import-section {
margin-top: 2rem;
text-align: center;
}
.sr-info {
background-color: var(--info-light);
border-left: 4px solid var(--info);
padding: 1rem;
border-radius: 4px;
margin: 1.5rem 0;
}
.sr-info h4 {
color: var(--info);
margin-top: 0;
margin-bottom: 0.5rem;
}
.sr-info p {
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.sr-difficulty {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.sr-difficulty-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.sr-difficulty-bar {
flex: 1;
height: 6px;
background-color: #eee;
border-radius: 3px;
overflow: hidden;
}
.sr-difficulty-fill {
height: 100%;
background: linear-gradient(to right, #1dd1a1, #feca57, #ff6b6b);
}
+152
View File
@@ -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"
}
}
}
}
+263 -1
View File
@@ -19,12 +19,67 @@ const DATA_DIR = path.join(__dirname, 'data');
const SEED_DIR = path.join(__dirname, 'seed');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
// Date utility functions for gamification features
function getWeekNumber(date) {
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
const pastDaysOfYear = (date - firstDayOfYear) / 86400000;
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
}
function getWeekStartDate(date) {
const result = new Date(date);
result.setDate(result.getDate() - result.getDay());
return result;
}
function getWeekEndDate(date) {
const result = new Date(date);
result.setDate(result.getDate() + (6 - result.getDay()));
return result;
}
function isToday(dateString) {
if (!dateString) return false;
const today = new Date().toISOString().split('T')[0];
return dateString.split('T')[0] === today;
}
function isSameDay(date1, date2) {
return date1.toISOString().split('T')[0] === date2.toISOString().split('T')[0];
}
function daysInARow(dateStrings) {
if (!dateStrings || !dateStrings.length) return 0;
// Convert strings to Date objects
const dates = dateStrings.map(d => new Date(d)).sort((a, b) => a - b);
let currentStreak = 1;
for (let i = 1; i < dates.length; i++) {
const prevDate = new Date(dates[i-1]);
prevDate.setDate(prevDate.getDate() + 1);
// If consecutive days
if (isSameDay(prevDate, dates[i])) {
currentStreak++;
} else {
break;
}
}
return currentStreak;
}
// 🗃 File mapping
const files = {
users: 'users.json',
wordlists: 'wordlists.json',
results: 'results.json',
badges: 'badges.json'
badges: 'badges.json',
leaderboards: 'leaderboards.json',
challenges: 'challenges.json',
progress: 'progress.json',
spacedRepetition: 'spacedRepetition.json'
};
// 🌱 Initialize data from seeds if missing/empty
@@ -82,6 +137,156 @@ function ensureFileWithSeed(name, fallback = '{}') {
};
fs.writeFileSync(targetPath, JSON.stringify(defaultBadges, null, 2));
console.log(`🏆 Created default badge structure`);
} else if (name === 'leaderboards.json') {
const defaultLeaderboards = {
"allTime": {
"accuracy": [],
"totalWords": [],
"sessionsCompleted": [],
"streakDays": []
},
"weekly": {
"accuracy": [],
"totalWords": [],
"sessionsCompleted": []
},
"lastUpdated": new Date().toISOString()
};
fs.writeFileSync(targetPath, JSON.stringify(defaultLeaderboards, null, 2));
console.log(`🏅 Created default leaderboards structure`);
} else if (name === 'challenges.json') {
const today = new Date();
const dayString = today.toISOString().split('T')[0];
const weekNum = getWeekNumber(today);
const yearNum = today.getFullYear();
const defaultChallenges = {
"daily": {
"current": {
"id": `daily-${dayString}`,
"title": `${new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric' })} Challenge`,
"description": "Complete a spelling session with at least 90% accuracy",
"type": "accuracy",
"target": 90,
"reward": {
"points": 50,
"badge": null
},
"date": dayString
},
"history": []
},
"weekly": {
"current": {
"id": `weekly-${yearNum}-${weekNum}`,
"title": `Week ${weekNum} Challenge`,
"description": "Practice spelling for 5 consecutive days",
"type": "streak",
"target": 5,
"reward": {
"points": 100,
"badge": "Weekly Warrior"
},
"startDate": getWeekStartDate(today).toISOString().split('T')[0],
"endDate": getWeekEndDate(today).toISOString().split('T')[0]
},
"history": []
},
"achievements": [
{
"id": "perfect-accuracy",
"title": "Perfect Accuracy",
"description": "Complete a session with 100% accuracy",
"type": "session",
"condition": "accuracy",
"target": 100,
"reward": {
"points": 75,
"badge": "Perfection"
}
},
{
"id": "persistent-learner",
"title": "Persistent Learner",
"description": "Practice for 7 days in a row",
"type": "streak",
"condition": "dailyLogin",
"target": 7,
"reward": {
"points": 200,
"badge": "Persistence"
}
},
{
"id": "spelling-veteran",
"title": "Spelling Veteran",
"description": "Complete 50 spelling sessions",
"type": "cumulative",
"condition": "sessions",
"target": 50,
"reward": {
"points": 300,
"badge": "Veteran"
}
}
],
"lastUpdated": new Date().toISOString()
};
fs.writeFileSync(targetPath, JSON.stringify(defaultChallenges, null, 2));
console.log(`🎯 Created default challenges structure`);
} else if (name === 'progress.json') {
// Create empty progress structure for each user
const users = readJsonSafe(path.join(DATA_DIR, files.users), []);
const defaultProgress = {};
users.forEach(user => {
defaultProgress[user.username] = {
"stats": {
"points": 0,
"totalSessions": 0,
"totalWords": 0,
"correctWords": 0,
"accuracy": 0
},
"streaks": {
"current": 0,
"longest": 0,
"lastActivity": null
},
"challengesCompleted": {
"daily": [],
"weekly": [],
"achievements": []
}
};
});
fs.writeFileSync(targetPath, JSON.stringify(defaultProgress, null, 2));
console.log(`📈 Created default progress structure for ${Object.keys(defaultProgress).length} users`);
} else if (name === 'spacedRepetition.json') {
// Create empty spaced repetition structure for each user
const users = readJsonSafe(path.join(DATA_DIR, files.users), []);
const defaultSRData = {};
users.forEach(user => {
defaultSRData[user.username] = {
"settings": {
"dailyLimit": 20,
"reviewThreshold": 3,
"newWordsPerDay": 5
},
"stats": {
"totalReviews": 0,
"correctReviews": 0,
"totalWords": 0,
"masteredWords": 0
},
"words": {}
};
});
fs.writeFileSync(targetPath, JSON.stringify(defaultSRData, null, 2));
console.log(`📚 Created default spaced repetition structure for ${Object.keys(defaultSRData).length} users`);
} else {
fs.writeFileSync(targetPath, fallback);
console.log(`📄 Initialized ${name} with default`);
@@ -161,6 +366,55 @@ function initializeUserInAllFiles(username) {
fs.writeFileSync(badgesPath, JSON.stringify(badges, null, 2));
console.log(`🏆 Added badge tracking for "${username}"`);
}
// Initialize in progress.json
const progressPath = path.join(DATA_DIR, files.progress);
const progress = readJsonSafe(progressPath, {});
if (!progress[username]) {
progress[username] = {
stats: {
points: 0,
totalSessions: 0,
totalWords: 0,
correctWords: 0,
accuracy: 0
},
streaks: {
current: 0,
longest: 0,
lastActivity: null
},
challengesCompleted: {
daily: [],
weekly: [],
achievements: []
}
};
fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2));
console.log(`📈 Added progress tracking for "${username}"`);
}
// Initialize in spacedRepetition.json
const srPath = path.join(DATA_DIR, files.spacedRepetition);
const srData = readJsonSafe(srPath, {});
if (!srData[username]) {
srData[username] = {
settings: {
dailyLimit: 20,
reviewThreshold: 3,
newWordsPerDay: 5
},
stats: {
totalReviews: 0,
correctReviews: 0,
totalWords: 0,
masteredWords: 0
},
words: {}
};
fs.writeFileSync(srPath, JSON.stringify(srData, null, 2));
console.log(`📚 Added spaced repetition data for "${username}"`);
}
}
// 🔐 Verify login
@@ -384,6 +638,14 @@ app.get('/getResults', (req, res) => {
res.json(results);
});
// Include gamification endpoints
const gamificationEndpoints = fs.readFileSync(path.join(__dirname, 'gamification-endpoints.js'), 'utf8');
eval(gamificationEndpoints);
// Include spaced repetition endpoints
const spacedRepetitionEndpoints = fs.readFileSync(path.join(__dirname, 'spaced-repetition-endpoints.js'), 'utf8');
eval(spacedRepetitionEndpoints);
// 📈 Enhanced Analytics API endpoint
app.get('/getAnalytics', (req, res) => {
try {
+157
View File
@@ -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');