Implement enhanced Analytics Dashboard with visualizations and detailed metrics
This commit is contained in:
1 parent
5644f564eb
commit
27af564a41
6 files changed
+1575
-53
No files matched your search
@@ -0,0 +1,164 @@
|
|||||||
|
// Enhanced Analytics API endpoint for the Spelling Practice App
|
||||||
|
|
||||||
|
// Get analytics data with additional metrics
|
||||||
|
app.get('/getAnalytics', (req, res) => {
|
||||||
|
try {
|
||||||
|
const results = readJsonSafe(path.join(DATA_DIR, files.results), {});
|
||||||
|
|
||||||
|
// Prepare analytics data structure
|
||||||
|
const analytics = {
|
||||||
|
summary: {
|
||||||
|
totalSessions: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
overallAccuracy: 0,
|
||||||
|
averageScore: 0,
|
||||||
|
mostMissedWords: [],
|
||||||
|
activityByDate: {}
|
||||||
|
},
|
||||||
|
userStats: {},
|
||||||
|
wordStats: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process all results data
|
||||||
|
Object.keys(results).forEach(username => {
|
||||||
|
// Skip if user has no results
|
||||||
|
if (!Array.isArray(results[username]) || results[username].length === 0) return;
|
||||||
|
|
||||||
|
// Initialize user stats if not already present
|
||||||
|
if (!analytics.userStats[username]) {
|
||||||
|
analytics.userStats[username] = {
|
||||||
|
totalSessions: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
averageScore: 0,
|
||||||
|
trend: [], // For progress over time
|
||||||
|
recentResults: [],
|
||||||
|
problemWords: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each session for this user
|
||||||
|
results[username].forEach(session => {
|
||||||
|
// Skip invalid sessions
|
||||||
|
if (!session || !session.answers || !Array.isArray(session.answers)) return;
|
||||||
|
|
||||||
|
// Update summary counts
|
||||||
|
analytics.summary.totalSessions++;
|
||||||
|
analytics.userStats[username].totalSessions++;
|
||||||
|
|
||||||
|
// Process session date for activity tracking
|
||||||
|
const sessionDate = new Date(session.timestamp || Date.now()).toISOString().split('T')[0];
|
||||||
|
analytics.summary.activityByDate[sessionDate] = (analytics.summary.activityByDate[sessionDate] || 0) + 1;
|
||||||
|
|
||||||
|
// Collect score for trending data
|
||||||
|
if (typeof session.score === 'number' && session.answers.length > 0) {
|
||||||
|
const accuracy = session.score / session.answers.length;
|
||||||
|
analytics.userStats[username].trend.push({
|
||||||
|
date: sessionDate,
|
||||||
|
accuracy: accuracy * 100,
|
||||||
|
score: session.score,
|
||||||
|
total: session.answers.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only most recent 10 sessions for the trend data
|
||||||
|
if (analytics.userStats[username].trend.length > 10) {
|
||||||
|
analytics.userStats[username].trend = analytics.userStats[username].trend.slice(-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the 5 most recent results for display
|
||||||
|
if (analytics.userStats[username].recentResults.length < 5) {
|
||||||
|
analytics.userStats[username].recentResults.push({
|
||||||
|
date: new Date(session.timestamp || Date.now()).toLocaleString(),
|
||||||
|
score: session.score,
|
||||||
|
total: session.answers.length,
|
||||||
|
completed: session.completed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process individual words in the session
|
||||||
|
session.answers.forEach(answer => {
|
||||||
|
// Skip invalid answers
|
||||||
|
if (!answer || typeof answer.word !== 'string') return;
|
||||||
|
|
||||||
|
// Update word stats
|
||||||
|
const word = answer.word.toLowerCase();
|
||||||
|
|
||||||
|
if (!analytics.wordStats[word]) {
|
||||||
|
analytics.wordStats[word] = {
|
||||||
|
attempts: 0,
|
||||||
|
correct: 0,
|
||||||
|
incorrect: 0,
|
||||||
|
accuracy: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
analytics.wordStats[word].attempts++;
|
||||||
|
analytics.summary.totalWords++;
|
||||||
|
analytics.userStats[username].totalWords++;
|
||||||
|
|
||||||
|
if (answer.correct) {
|
||||||
|
analytics.wordStats[word].correct++;
|
||||||
|
analytics.summary.correctWords++;
|
||||||
|
analytics.userStats[username].correctWords++;
|
||||||
|
} else {
|
||||||
|
analytics.wordStats[word].incorrect++;
|
||||||
|
|
||||||
|
// Track problem words for this user
|
||||||
|
const problemWordIndex = analytics.userStats[username].problemWords.findIndex(pw => pw.word === word);
|
||||||
|
|
||||||
|
if (problemWordIndex >= 0) {
|
||||||
|
analytics.userStats[username].problemWords[problemWordIndex].count++;
|
||||||
|
} else {
|
||||||
|
analytics.userStats[username].problemWords.push({
|
||||||
|
word,
|
||||||
|
count: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate accuracy for this word
|
||||||
|
analytics.wordStats[word].accuracy =
|
||||||
|
analytics.wordStats[word].correct / analytics.wordStats[word].attempts * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate user accuracy and average score
|
||||||
|
analytics.userStats[username].accuracy =
|
||||||
|
analytics.userStats[username].correctWords / analytics.userStats[username].totalWords * 100 || 0;
|
||||||
|
analytics.userStats[username].averageScore =
|
||||||
|
analytics.userStats[username].correctWords / analytics.userStats[username].totalSessions || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort problem words by count (descending)
|
||||||
|
analytics.userStats[username].problemWords.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
// Keep only top 5 problem words
|
||||||
|
analytics.userStats[username].problemWords = analytics.userStats[username].problemWords.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate overall metrics
|
||||||
|
analytics.summary.overallAccuracy =
|
||||||
|
analytics.summary.correctWords / analytics.summary.totalWords * 100 || 0;
|
||||||
|
analytics.summary.averageScore =
|
||||||
|
analytics.summary.correctWords / analytics.summary.totalSessions || 0;
|
||||||
|
|
||||||
|
// Find most missed words (top 5)
|
||||||
|
const missedWords = Object.keys(analytics.wordStats)
|
||||||
|
.map(word => ({
|
||||||
|
word,
|
||||||
|
missCount: analytics.wordStats[word].incorrect
|
||||||
|
}))
|
||||||
|
.filter(item => item.missCount > 0)
|
||||||
|
.sort((a, b) => b.missCount - a.missCount)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
analytics.summary.mostMissedWords = missedWords;
|
||||||
|
|
||||||
|
res.json(analytics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating analytics:', error);
|
||||||
|
res.status(500).send('Error generating analytics data');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/* Analytics Dashboard Styles */
|
||||||
|
|
||||||
|
/* Main grid layout for analytics dashboard */
|
||||||
|
.analytics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat cards styling */
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h5 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-change {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-change.positive {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-change.negative {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart containers */
|
||||||
|
.chart-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
grid-column: span 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Problem words list */
|
||||||
|
#problemWordsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem-word-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem-word {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem-count {
|
||||||
|
background: var(--bg-accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity feed */
|
||||||
|
.activity-feed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-user {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data table */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:nth-child(even) {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
#studentBreakdownContainer {
|
||||||
|
height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini sparkline charts */
|
||||||
|
.sparkline {
|
||||||
|
display: inline-block;
|
||||||
|
height: 24px;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.analytics-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.analytics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Activity heatmap */
|
||||||
|
.heatmap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-day {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-day:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip styles */
|
||||||
|
.chart-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
+87
-12
@@ -8,6 +8,7 @@
|
|||||||
<link href="mobile.css" rel="stylesheet"/>
|
<link href="mobile.css" rel="stylesheet"/>
|
||||||
<link href="tab-contrast.css" rel="stylesheet"/>
|
<link href="tab-contrast.css" rel="stylesheet"/>
|
||||||
<link href="badges.css" rel="stylesheet"/>
|
<link href="badges.css" rel="stylesheet"/>
|
||||||
|
<link href="analytics.css" rel="stylesheet"/>
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
|
||||||
<script src="js/auth.js"></script>
|
<script src="js/auth.js"></script>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<script src="js/export.js"></script>
|
<script src="js/export.js"></script>
|
||||||
<script src="js/sessions.js"></script>
|
<script src="js/sessions.js"></script>
|
||||||
<script src="js/admin-badges.js"></script>
|
<script src="js/admin-badges.js"></script>
|
||||||
|
<script src="js/analytics.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -163,22 +165,95 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>📊 Performance Analytics</h3>
|
<h3>📊 Performance Analytics</h3>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<select id="analyticsUserSelect" class="form-control">
|
||||||
|
<option value="all">All Users</option>
|
||||||
|
<!-- Additional users will be added here dynamically -->
|
||||||
|
</select>
|
||||||
|
<select id="analyticsTimeRange" class="form-control">
|
||||||
|
<option value="all">All Time</option>
|
||||||
|
<option value="week">Last Week</option>
|
||||||
|
<option value="month">Last Month</option>
|
||||||
|
<option value="quarter">Last 3 Months</option>
|
||||||
|
</select>
|
||||||
<button onclick="refreshAnalytics()" class="btn-primary">🔄 Refresh Analytics</button>
|
<button onclick="refreshAnalytics()" class="btn-primary">🔄 Refresh Analytics</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
|
|
||||||
<div>
|
|
||||||
<h4>Summary Statistics</h4>
|
|
||||||
<ul id="analyticsSummary" style="background: var(--bg-secondary); border-radius: var(--radius); padding: 1rem;">
|
|
||||||
<li><strong>Average Score:</strong> –</li>
|
|
||||||
<li><strong>Total Sessions:</strong> –</li>
|
|
||||||
<li><strong>Most Missed Word:</strong> –</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="analytics-grid">
|
||||||
<h4>Student Breakdown</h4>
|
<!-- Dashboard Summary Cards -->
|
||||||
<ul id="studentBreakdown" style="max-height: 300px; overflow-y: auto;"></ul>
|
<div class="stat-card">
|
||||||
|
<h5>Total Sessions</h5>
|
||||||
|
<div class="stat-value" id="statTotalSessions">-</div>
|
||||||
|
<div class="stat-change" id="statSessionsChange"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<h5>Overall Accuracy</h5>
|
||||||
|
<div class="stat-value" id="statOverallAccuracy">-</div>
|
||||||
|
<div class="stat-change" id="statAccuracyChange"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<h5>Total Words</h5>
|
||||||
|
<div class="stat-value" id="statTotalWords">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<h5>Average Score</h5>
|
||||||
|
<div class="stat-value" id="statAverageScore">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Over Time Chart -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Progress Over Time</h4>
|
||||||
|
<div id="progressChartContainer" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Heatmap -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Activity Calendar</h4>
|
||||||
|
<div id="activityHeatmap" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Problem Words -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Most Challenging Words</h4>
|
||||||
|
<div id="problemWordsContainer">
|
||||||
|
<div id="problemWordsList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Performance Comparison -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>User Performance Comparison</h4>
|
||||||
|
<div id="userComparisonChart" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Student Breakdown (Updated) -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Student Performance</h4>
|
||||||
|
<div id="studentBreakdownContainer">
|
||||||
|
<table id="studentBreakdownTable" class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Student</th>
|
||||||
|
<th>Sessions</th>
|
||||||
|
<th>Words</th>
|
||||||
|
<th>Accuracy</th>
|
||||||
|
<th>Trend</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="studentBreakdown">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Recent Activity</h4>
|
||||||
|
<div id="recentActivityList" class="activity-feed"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+18
-41
@@ -457,58 +457,35 @@ async function loadUserDropdowns() {
|
|||||||
console.error("⚠️ Failed to load user list:", err);
|
console.error("⚠️ Failed to load user list:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Analytics functionality has been moved to analytics.js
|
||||||
|
// This is now just a redirecting function
|
||||||
async function refreshAnalytics() {
|
async function refreshAnalytics() {
|
||||||
|
// The implementation is in analytics.js
|
||||||
|
if (typeof window.refreshAnalytics === 'function') {
|
||||||
|
return window.refreshAnalytics();
|
||||||
|
} else {
|
||||||
|
console.log("Loading legacy analytics implementation...");
|
||||||
|
try {
|
||||||
const summary = document.getElementById("analyticsSummary");
|
const summary = document.getElementById("analyticsSummary");
|
||||||
const breakdown = document.getElementById("studentBreakdown");
|
const breakdown = document.getElementById("studentBreakdown");
|
||||||
summary.innerHTML = "<li>Loading...</li>";
|
|
||||||
breakdown.innerHTML = "";
|
|
||||||
|
|
||||||
try {
|
if (summary) summary.innerHTML = "<li>Loading...</li>";
|
||||||
|
if (breakdown) breakdown.innerHTML = "";
|
||||||
|
|
||||||
const res = await fetch('/getResults');
|
const res = await fetch('/getResults');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
let totalScore = 0, sessionCount = 0;
|
// Legacy implementation...
|
||||||
const missMap = {};
|
console.log("Analytics data loaded:", Object.keys(data).length, "users");
|
||||||
|
|
||||||
for (const username in data) {
|
|
||||||
const { score, completed, answers } = data[username];
|
|
||||||
if (!completed || !Array.isArray(answers)) continue;
|
|
||||||
|
|
||||||
totalScore += score;
|
|
||||||
sessionCount++;
|
|
||||||
|
|
||||||
// Count missed words
|
|
||||||
answers.forEach(({ word, correct }) => {
|
|
||||||
if (!correct) {
|
|
||||||
missMap[word] = (missMap[word] || 0) + 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Per-student breakdown
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.innerHTML = `<strong>${username}</strong>: ${score}/${answers.length} correct`;
|
|
||||||
breakdown.appendChild(li);
|
|
||||||
}
|
|
||||||
|
|
||||||
const avgScore = sessionCount ? (totalScore / sessionCount).toFixed(2) : "0";
|
|
||||||
let mostMissed = "-", highestMisses = 0;
|
|
||||||
for (const word in missMap) {
|
|
||||||
if (missMap[word] > highestMisses) {
|
|
||||||
highestMisses = missMap[word];
|
|
||||||
mostMissed = word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summary.innerHTML = `
|
|
||||||
<li><strong>Average Score:</strong> ${avgScore}</li>
|
|
||||||
<li><strong>Total Completed Sessions:</strong> ${sessionCount}</li>
|
|
||||||
<li><strong>Most Missed Word:</strong> ${mostMissed} (${highestMisses} misses)</li>
|
|
||||||
`;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("📉 Failed to refresh analytics:", err);
|
console.error("📉 Failed to refresh analytics:", err);
|
||||||
summary.innerHTML = '<li style="color:red">Error loading analytics.</li>';
|
const summary = document.getElementById("analyticsSummary");
|
||||||
|
if (summary) summary.innerHTML = '<li style="color:red">Error loading analytics.</li>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBadgeViewer() {
|
async function loadBadgeViewer() {
|
||||||
const res = await fetch('/getBadges');
|
const res = await fetch('/getBadges');
|
||||||
const badgeData = await res.json();
|
const badgeData = await res.json();
|
||||||
|
|||||||
@@ -0,0 +1,905 @@
|
|||||||
|
// Enhanced Analytics functionality for the Spelling Practice App
|
||||||
|
|
||||||
|
// Global analytics data
|
||||||
|
let analyticsData = null;
|
||||||
|
let selectedUser = 'all';
|
||||||
|
let selectedTimeRange = 'all';
|
||||||
|
|
||||||
|
// Initialize analytics components
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Setup user selection dropdown events
|
||||||
|
const userSelect = document.getElementById('analyticsUserSelect');
|
||||||
|
if (userSelect) {
|
||||||
|
userSelect.addEventListener('change', function() {
|
||||||
|
selectedUser = this.value;
|
||||||
|
renderAnalyticsDashboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup time range selection dropdown events
|
||||||
|
const timeRangeSelect = document.getElementById('analyticsTimeRange');
|
||||||
|
if (timeRangeSelect) {
|
||||||
|
timeRangeSelect.addEventListener('change', function() {
|
||||||
|
selectedTimeRange = this.value;
|
||||||
|
renderAnalyticsDashboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main function to refresh analytics
|
||||||
|
async function refreshAnalytics() {
|
||||||
|
try {
|
||||||
|
// Update UI to show loading state
|
||||||
|
updateLoadingState(true);
|
||||||
|
|
||||||
|
// Fetch analytics data from server
|
||||||
|
const response = await fetch('/getAnalytics');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch analytics data: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
analyticsData = await response.json();
|
||||||
|
|
||||||
|
// Populate user dropdown
|
||||||
|
populateUserDropdown();
|
||||||
|
|
||||||
|
// Render the analytics dashboard with the new data
|
||||||
|
renderAnalyticsDashboard();
|
||||||
|
|
||||||
|
// Update UI to show data is loaded
|
||||||
|
updateLoadingState(false);
|
||||||
|
|
||||||
|
console.log('📊 Analytics data loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing analytics:', error);
|
||||||
|
updateLoadingState(false);
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
const errorMessage = document.createElement('div');
|
||||||
|
errorMessage.className = 'error-message';
|
||||||
|
errorMessage.textContent = 'Failed to load analytics data. Please try again.';
|
||||||
|
|
||||||
|
const chartContainers = document.querySelectorAll('.chart-container');
|
||||||
|
chartContainers.forEach(container => {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(errorMessage.cloneNode(true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI elements to show loading state
|
||||||
|
function updateLoadingState(isLoading) {
|
||||||
|
const loadingTemplate = '<div class="loading-spinner"></div>';
|
||||||
|
|
||||||
|
// Update stat cards
|
||||||
|
document.getElementById('statTotalSessions').innerHTML = isLoading ? loadingTemplate : document.getElementById('statTotalSessions').innerHTML;
|
||||||
|
document.getElementById('statOverallAccuracy').innerHTML = isLoading ? loadingTemplate : document.getElementById('statOverallAccuracy').innerHTML;
|
||||||
|
document.getElementById('statTotalWords').innerHTML = isLoading ? loadingTemplate : document.getElementById('statTotalWords').innerHTML;
|
||||||
|
document.getElementById('statAverageScore').innerHTML = isLoading ? loadingTemplate : document.getElementById('statAverageScore').innerHTML;
|
||||||
|
|
||||||
|
// Update charts
|
||||||
|
if (isLoading) {
|
||||||
|
document.getElementById('progressChartContainer').innerHTML = loadingTemplate;
|
||||||
|
document.getElementById('activityHeatmap').innerHTML = loadingTemplate;
|
||||||
|
document.getElementById('problemWordsList').innerHTML = loadingTemplate;
|
||||||
|
document.getElementById('userComparisonChart').innerHTML = loadingTemplate;
|
||||||
|
document.getElementById('studentBreakdown').innerHTML = '<tr><td colspan="5" class="text-center">' + loadingTemplate + '</td></tr>';
|
||||||
|
document.getElementById('recentActivityList').innerHTML = loadingTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate user dropdown with available users
|
||||||
|
function populateUserDropdown() {
|
||||||
|
const userSelect = document.getElementById('analyticsUserSelect');
|
||||||
|
if (!userSelect || !analyticsData) return;
|
||||||
|
|
||||||
|
// Clear existing options except the first one (All Users)
|
||||||
|
while (userSelect.options.length > 1) {
|
||||||
|
userSelect.remove(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all users from the analytics data
|
||||||
|
const users = Object.keys(analyticsData.userStats || {});
|
||||||
|
users.forEach(username => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = username;
|
||||||
|
option.textContent = username;
|
||||||
|
userSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the entire analytics dashboard
|
||||||
|
function renderAnalyticsDashboard() {
|
||||||
|
if (!analyticsData) {
|
||||||
|
console.warn('No analytics data available to render');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter data based on selected user and time range
|
||||||
|
const filteredData = filterAnalyticsData();
|
||||||
|
|
||||||
|
// Update summary statistics
|
||||||
|
updateSummaryStats(filteredData);
|
||||||
|
|
||||||
|
// Render progress chart
|
||||||
|
renderProgressChart(filteredData);
|
||||||
|
|
||||||
|
// Render activity heatmap
|
||||||
|
renderActivityHeatmap(filteredData);
|
||||||
|
|
||||||
|
// Render problem words list
|
||||||
|
renderProblemWordsList(filteredData);
|
||||||
|
|
||||||
|
// Render user comparison chart
|
||||||
|
renderUserComparisonChart(filteredData);
|
||||||
|
|
||||||
|
// Render student breakdown table
|
||||||
|
renderStudentBreakdownTable(filteredData);
|
||||||
|
|
||||||
|
// Render recent activity feed
|
||||||
|
renderRecentActivityFeed(filteredData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter analytics data based on selected user and time range
|
||||||
|
function filterAnalyticsData() {
|
||||||
|
if (!analyticsData) return null;
|
||||||
|
|
||||||
|
// Clone the data to avoid modifying the original
|
||||||
|
const filtered = JSON.parse(JSON.stringify(analyticsData));
|
||||||
|
|
||||||
|
// Filter by user if not 'all'
|
||||||
|
if (selectedUser !== 'all') {
|
||||||
|
// Keep only the selected user's data
|
||||||
|
filtered.userStats = {
|
||||||
|
[selectedUser]: filtered.userStats[selectedUser]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by time range
|
||||||
|
if (selectedTimeRange !== 'all') {
|
||||||
|
const cutoffDate = getTimeRangeCutoffDate();
|
||||||
|
|
||||||
|
// Filter activity dates
|
||||||
|
Object.keys(filtered.summary.activityByDate).forEach(date => {
|
||||||
|
if (new Date(date) < cutoffDate) {
|
||||||
|
delete filtered.summary.activityByDate[date];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter user trends
|
||||||
|
Object.keys(filtered.userStats).forEach(username => {
|
||||||
|
const user = filtered.userStats[username];
|
||||||
|
if (user.trend) {
|
||||||
|
user.trend = user.trend.filter(item => new Date(item.date) >= cutoffDate);
|
||||||
|
}
|
||||||
|
if (user.recentResults) {
|
||||||
|
user.recentResults = user.recentResults.filter(item => new Date(item.date) >= cutoffDate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cutoff date based on selected time range
|
||||||
|
function getTimeRangeCutoffDate() {
|
||||||
|
const now = new Date();
|
||||||
|
switch (selectedTimeRange) {
|
||||||
|
case 'week':
|
||||||
|
return new Date(now.setDate(now.getDate() - 7));
|
||||||
|
case 'month':
|
||||||
|
return new Date(now.setMonth(now.getMonth() - 1));
|
||||||
|
case 'quarter':
|
||||||
|
return new Date(now.setMonth(now.getMonth() - 3));
|
||||||
|
default:
|
||||||
|
return new Date(0); // Beginning of time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary statistics cards
|
||||||
|
function updateSummaryStats(data) {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Calculate summary stats based on filtered data
|
||||||
|
let totalSessions = 0;
|
||||||
|
let totalWords = 0;
|
||||||
|
let totalCorrect = 0;
|
||||||
|
|
||||||
|
// If we're viewing a single user
|
||||||
|
if (selectedUser !== 'all' && data.userStats[selectedUser]) {
|
||||||
|
const userData = data.userStats[selectedUser];
|
||||||
|
totalSessions = userData.totalSessions || 0;
|
||||||
|
totalWords = userData.totalWords || 0;
|
||||||
|
totalCorrect = userData.correctWords || 0;
|
||||||
|
}
|
||||||
|
// Otherwise use summary data
|
||||||
|
else {
|
||||||
|
// Count sessions from activity data
|
||||||
|
totalSessions = Object.values(data.summary.activityByDate || {}).reduce((sum, count) => sum + count, 0);
|
||||||
|
|
||||||
|
// Count words from all users
|
||||||
|
Object.values(data.userStats || {}).forEach(user => {
|
||||||
|
totalWords += user.totalWords || 0;
|
||||||
|
totalCorrect += user.correctWords || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate accuracy and average
|
||||||
|
const accuracy = totalWords > 0 ? (totalCorrect / totalWords * 100).toFixed(1) : 0;
|
||||||
|
const averageScore = totalSessions > 0 ? (totalCorrect / totalSessions).toFixed(1) : 0;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.getElementById('statTotalSessions').textContent = totalSessions;
|
||||||
|
document.getElementById('statOverallAccuracy').textContent = `${accuracy}%`;
|
||||||
|
document.getElementById('statTotalWords').textContent = totalWords;
|
||||||
|
document.getElementById('statAverageScore').textContent = averageScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the progress over time chart
|
||||||
|
function renderProgressChart(data) {
|
||||||
|
const container = document.getElementById('progressChartContainer');
|
||||||
|
if (!container || !data) return;
|
||||||
|
|
||||||
|
// Clear previous chart
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Get trend data
|
||||||
|
let trendData = [];
|
||||||
|
|
||||||
|
if (selectedUser !== 'all' && data.userStats[selectedUser]) {
|
||||||
|
// Get trend for specific user
|
||||||
|
trendData = data.userStats[selectedUser].trend || [];
|
||||||
|
} else {
|
||||||
|
// Aggregate trends from all users
|
||||||
|
Object.values(data.userStats || {}).forEach(user => {
|
||||||
|
if (user.trend && user.trend.length) {
|
||||||
|
// Group by date and average the accuracy
|
||||||
|
user.trend.forEach(item => {
|
||||||
|
const existingItem = trendData.find(t => t.date === item.date);
|
||||||
|
if (existingItem) {
|
||||||
|
existingItem.totalAccuracy += item.accuracy;
|
||||||
|
existingItem.count++;
|
||||||
|
} else {
|
||||||
|
trendData.push({
|
||||||
|
date: item.date,
|
||||||
|
totalAccuracy: item.accuracy,
|
||||||
|
count: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate average accuracy for each date
|
||||||
|
trendData = trendData.map(item => ({
|
||||||
|
date: item.date,
|
||||||
|
accuracy: item.totalAccuracy / item.count
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort by date
|
||||||
|
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trendData.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No progress data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SVG
|
||||||
|
const width = container.clientWidth || 500;
|
||||||
|
const height = container.clientHeight || 200;
|
||||||
|
const padding = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||||
|
const chartWidth = width - padding.left - padding.right;
|
||||||
|
const chartHeight = height - padding.top - padding.bottom;
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', width);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
svg.style.overflow = 'visible';
|
||||||
|
container.appendChild(svg);
|
||||||
|
|
||||||
|
// Scale functions
|
||||||
|
const xScale = index => (index / (trendData.length - 1 || 1)) * chartWidth + padding.left;
|
||||||
|
const yScale = value => chartHeight - (value / 100) * chartHeight + padding.top;
|
||||||
|
|
||||||
|
// Create path for the line
|
||||||
|
const pathData = trendData.map((point, i) => {
|
||||||
|
const x = xScale(i);
|
||||||
|
const y = yScale(point.accuracy || 0);
|
||||||
|
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
// Add the path
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.setAttribute('d', pathData);
|
||||||
|
path.setAttribute('stroke', 'var(--primary)');
|
||||||
|
path.setAttribute('stroke-width', '3');
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
svg.appendChild(path);
|
||||||
|
|
||||||
|
// Add area under the curve
|
||||||
|
const areaPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
const areaPathData = `${pathData} L ${xScale(trendData.length - 1)} ${chartHeight + padding.top} L ${xScale(0)} ${chartHeight + padding.top} Z`;
|
||||||
|
areaPath.setAttribute('d', areaPathData);
|
||||||
|
areaPath.setAttribute('fill', 'var(--primary)');
|
||||||
|
areaPath.setAttribute('opacity', '0.1');
|
||||||
|
svg.appendChild(areaPath);
|
||||||
|
|
||||||
|
// Add data points
|
||||||
|
trendData.forEach((point, i) => {
|
||||||
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
circle.setAttribute('cx', xScale(i));
|
||||||
|
circle.setAttribute('cy', yScale(point.accuracy || 0));
|
||||||
|
circle.setAttribute('r', '4');
|
||||||
|
circle.setAttribute('fill', 'var(--primary)');
|
||||||
|
|
||||||
|
// Add tooltip data
|
||||||
|
circle.setAttribute('data-date', point.date);
|
||||||
|
circle.setAttribute('data-accuracy', `${Math.round(point.accuracy || 0)}%`);
|
||||||
|
|
||||||
|
// Add event listeners for tooltip
|
||||||
|
circle.addEventListener('mouseenter', showTooltip);
|
||||||
|
circle.addEventListener('mouseleave', hideTooltip);
|
||||||
|
|
||||||
|
svg.appendChild(circle);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add axes
|
||||||
|
const xAxis = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
xAxis.setAttribute('x1', padding.left);
|
||||||
|
xAxis.setAttribute('y1', chartHeight + padding.top);
|
||||||
|
xAxis.setAttribute('x2', chartWidth + padding.left);
|
||||||
|
xAxis.setAttribute('y2', chartHeight + padding.top);
|
||||||
|
xAxis.setAttribute('stroke', '#ccc');
|
||||||
|
svg.appendChild(xAxis);
|
||||||
|
|
||||||
|
const yAxis = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
yAxis.setAttribute('x1', padding.left);
|
||||||
|
yAxis.setAttribute('y1', padding.top);
|
||||||
|
yAxis.setAttribute('x2', padding.left);
|
||||||
|
yAxis.setAttribute('y2', chartHeight + padding.top);
|
||||||
|
yAxis.setAttribute('stroke', '#ccc');
|
||||||
|
svg.appendChild(yAxis);
|
||||||
|
|
||||||
|
// Add Y-axis labels (0%, 50%, 100%)
|
||||||
|
[0, 50, 100].forEach(value => {
|
||||||
|
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
label.setAttribute('x', padding.left - 5);
|
||||||
|
label.setAttribute('y', yScale(value));
|
||||||
|
label.setAttribute('text-anchor', 'end');
|
||||||
|
label.setAttribute('dominant-baseline', 'middle');
|
||||||
|
label.setAttribute('font-size', '10px');
|
||||||
|
label.setAttribute('fill', 'var(--text-secondary)');
|
||||||
|
label.textContent = `${value}%`;
|
||||||
|
svg.appendChild(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add X-axis labels (dates)
|
||||||
|
if (trendData.length > 0) {
|
||||||
|
[0, Math.floor(trendData.length / 2), trendData.length - 1].forEach(i => {
|
||||||
|
if (i < trendData.length) {
|
||||||
|
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
label.setAttribute('x', xScale(i));
|
||||||
|
label.setAttribute('y', chartHeight + padding.top + 15);
|
||||||
|
label.setAttribute('text-anchor', 'middle');
|
||||||
|
label.setAttribute('font-size', '10px');
|
||||||
|
label.setAttribute('fill', 'var(--text-secondary)');
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
const date = new Date(trendData[i].date);
|
||||||
|
label.textContent = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
|
svg.appendChild(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show tooltip for chart data points
|
||||||
|
function showTooltip(event) {
|
||||||
|
// Get tooltip element or create one if it doesn't exist
|
||||||
|
let tooltip = document.querySelector('.chart-tooltip');
|
||||||
|
if (!tooltip) {
|
||||||
|
tooltip = document.createElement('div');
|
||||||
|
tooltip.className = 'chart-tooltip';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get data from the point
|
||||||
|
const date = this.getAttribute('data-date');
|
||||||
|
const accuracy = this.getAttribute('data-accuracy');
|
||||||
|
|
||||||
|
// Format the content
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div><strong>Date:</strong> ${new Date(date).toLocaleDateString()}</div>
|
||||||
|
<div><strong>Accuracy:</strong> ${accuracy}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Position the tooltip
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
tooltip.style.left = `${rect.left}px`;
|
||||||
|
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 10}px`;
|
||||||
|
|
||||||
|
// Show the tooltip
|
||||||
|
tooltip.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide tooltip for chart data points
|
||||||
|
function hideTooltip() {
|
||||||
|
const tooltip = document.querySelector('.chart-tooltip');
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.style.opacity = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render activity heatmap
|
||||||
|
function renderActivityHeatmap(data) {
|
||||||
|
const container = document.getElementById('activityHeatmap');
|
||||||
|
if (!container || !data) return;
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Get activity data
|
||||||
|
const activityData = data.summary.activityByDate || {};
|
||||||
|
|
||||||
|
// If no data, show empty state
|
||||||
|
if (Object.keys(activityData).length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No activity data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container for heatmap
|
||||||
|
const heatmapContainer = document.createElement('div');
|
||||||
|
heatmapContainer.className = 'heatmap';
|
||||||
|
container.appendChild(heatmapContainer);
|
||||||
|
|
||||||
|
// Get date range (last 90 days)
|
||||||
|
const today = new Date();
|
||||||
|
const days = [];
|
||||||
|
for (let i = 90; i >= 0; i--) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(today.getDate() - i);
|
||||||
|
days.push(date.toISOString().split('T')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find max activity count for color scaling
|
||||||
|
const maxCount = Math.max(...Object.values(activityData).map(count => count || 0), 1);
|
||||||
|
|
||||||
|
// Create day elements
|
||||||
|
days.forEach(day => {
|
||||||
|
const count = activityData[day] || 0;
|
||||||
|
const intensity = count / maxCount;
|
||||||
|
|
||||||
|
const dayEl = document.createElement('div');
|
||||||
|
dayEl.className = 'heatmap-day';
|
||||||
|
dayEl.style.backgroundColor = getHeatColor(intensity);
|
||||||
|
dayEl.setAttribute('data-date', day);
|
||||||
|
dayEl.setAttribute('data-count', count);
|
||||||
|
|
||||||
|
// Add tooltip
|
||||||
|
dayEl.addEventListener('mouseenter', function(e) {
|
||||||
|
const date = new Date(this.getAttribute('data-date')).toLocaleDateString();
|
||||||
|
const count = this.getAttribute('data-count');
|
||||||
|
|
||||||
|
// Show tooltip
|
||||||
|
let tooltip = document.querySelector('.chart-tooltip');
|
||||||
|
if (!tooltip) {
|
||||||
|
tooltip = document.createElement('div');
|
||||||
|
tooltip.className = 'chart-tooltip';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div><strong>Date:</strong> ${date}</div>
|
||||||
|
<div><strong>Sessions:</strong> ${count}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Position tooltip
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
tooltip.style.left = `${rect.left}px`;
|
||||||
|
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 10}px`;
|
||||||
|
|
||||||
|
// Show tooltip
|
||||||
|
tooltip.style.opacity = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
dayEl.addEventListener('mouseleave', function() {
|
||||||
|
const tooltip = document.querySelector('.chart-tooltip');
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.style.opacity = '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
heatmapContainer.appendChild(dayEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add legend
|
||||||
|
const legend = document.createElement('div');
|
||||||
|
legend.className = 'heatmap-legend';
|
||||||
|
legend.style.display = 'flex';
|
||||||
|
legend.style.alignItems = 'center';
|
||||||
|
legend.style.justifyContent = 'center';
|
||||||
|
legend.style.marginTop = '10px';
|
||||||
|
legend.style.gap = '5px';
|
||||||
|
|
||||||
|
const legendText1 = document.createElement('span');
|
||||||
|
legendText1.textContent = 'Less';
|
||||||
|
legendText1.style.fontSize = '0.8rem';
|
||||||
|
legendText1.style.color = 'var(--text-secondary)';
|
||||||
|
legend.appendChild(legendText1);
|
||||||
|
|
||||||
|
[0, 0.25, 0.5, 0.75, 1].forEach(intensity => {
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.style.width = '12px';
|
||||||
|
box.style.height = '12px';
|
||||||
|
box.style.backgroundColor = getHeatColor(intensity);
|
||||||
|
box.style.borderRadius = '2px';
|
||||||
|
legend.appendChild(box);
|
||||||
|
});
|
||||||
|
|
||||||
|
const legendText2 = document.createElement('span');
|
||||||
|
legendText2.textContent = 'More';
|
||||||
|
legendText2.style.fontSize = '0.8rem';
|
||||||
|
legendText2.style.color = 'var(--text-secondary)';
|
||||||
|
legend.appendChild(legendText2);
|
||||||
|
|
||||||
|
container.appendChild(legend);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get color for heatmap based on intensity
|
||||||
|
function getHeatColor(intensity) {
|
||||||
|
// Color gradient from light to dark blue
|
||||||
|
if (intensity === 0) return 'var(--bg-primary)';
|
||||||
|
|
||||||
|
// Use HSL to create a gradient from light to dark blue
|
||||||
|
const h = 210; // blue hue
|
||||||
|
const s = Math.min(100, 30 + intensity * 70); // saturation increases with intensity
|
||||||
|
const l = Math.max(30, 80 - intensity * 50); // lightness decreases with intensity
|
||||||
|
|
||||||
|
return `hsl(${h}, ${s}%, ${l}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render problem words list
|
||||||
|
function renderProblemWordsList(data) {
|
||||||
|
const container = document.getElementById('problemWordsList');
|
||||||
|
if (!container || !data) return;
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Get problem words
|
||||||
|
let problemWords = [];
|
||||||
|
|
||||||
|
if (selectedUser !== 'all' && data.userStats[selectedUser]) {
|
||||||
|
// Get problem words for specific user
|
||||||
|
problemWords = data.userStats[selectedUser].problemWords || [];
|
||||||
|
} else {
|
||||||
|
// Use the most missed words from summary
|
||||||
|
problemWords = data.summary.mostMissedWords || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no data, show empty state
|
||||||
|
if (problemWords.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No problem words data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create list items for each problem word
|
||||||
|
problemWords.forEach(wordItem => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'problem-word-item';
|
||||||
|
|
||||||
|
const word = document.createElement('div');
|
||||||
|
word.className = 'problem-word';
|
||||||
|
word.textContent = wordItem.word || wordItem.word;
|
||||||
|
|
||||||
|
const count = document.createElement('div');
|
||||||
|
count.className = 'problem-count';
|
||||||
|
count.textContent = wordItem.missCount || wordItem.count || 0;
|
||||||
|
|
||||||
|
item.appendChild(word);
|
||||||
|
item.appendChild(count);
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render user comparison chart
|
||||||
|
function renderUserComparisonChart(data) {
|
||||||
|
const container = document.getElementById('userComparisonChart');
|
||||||
|
if (!container || !data) return;
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Get user stats
|
||||||
|
const userStats = data.userStats || {};
|
||||||
|
|
||||||
|
// If no data or only one user selected, show message
|
||||||
|
if (Object.keys(userStats).length === 0 || (selectedUser !== 'all' && Object.keys(userStats).length === 1)) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No comparison data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SVG
|
||||||
|
const width = container.clientWidth || 500;
|
||||||
|
const height = container.clientHeight || 200;
|
||||||
|
const barPadding = 0.2;
|
||||||
|
const chartPadding = { top: 20, right: 20, bottom: 60, left: 40 };
|
||||||
|
const chartWidth = width - chartPadding.left - chartPadding.right;
|
||||||
|
const chartHeight = height - chartPadding.top - chartPadding.bottom;
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', width);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
svg.style.overflow = 'visible';
|
||||||
|
container.appendChild(svg);
|
||||||
|
|
||||||
|
// Prepare data for chart
|
||||||
|
const chartData = Object.keys(userStats).map(username => ({
|
||||||
|
username,
|
||||||
|
accuracy: userStats[username].accuracy || 0
|
||||||
|
})).sort((a, b) => b.accuracy - a.accuracy);
|
||||||
|
|
||||||
|
// Scale functions
|
||||||
|
const xScale = index => (index / chartData.length) * chartWidth + chartPadding.left;
|
||||||
|
const barWidth = chartWidth / chartData.length * (1 - barPadding);
|
||||||
|
|
||||||
|
// Create bars
|
||||||
|
chartData.forEach((user, i) => {
|
||||||
|
const x = xScale(i);
|
||||||
|
const barHeight = (user.accuracy / 100) * chartHeight;
|
||||||
|
const y = chartHeight - barHeight + chartPadding.top;
|
||||||
|
|
||||||
|
// Bar
|
||||||
|
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
|
rect.setAttribute('x', x);
|
||||||
|
rect.setAttribute('y', y);
|
||||||
|
rect.setAttribute('width', barWidth);
|
||||||
|
rect.setAttribute('height', barHeight);
|
||||||
|
rect.setAttribute('fill', 'var(--primary)');
|
||||||
|
rect.setAttribute('rx', '3');
|
||||||
|
|
||||||
|
// Add hover effects and tooltip data
|
||||||
|
rect.setAttribute('data-username', user.username);
|
||||||
|
rect.setAttribute('data-accuracy', `${user.accuracy.toFixed(1)}%`);
|
||||||
|
rect.addEventListener('mouseenter', showBarTooltip);
|
||||||
|
rect.addEventListener('mouseleave', hideTooltip);
|
||||||
|
|
||||||
|
svg.appendChild(rect);
|
||||||
|
|
||||||
|
// User label
|
||||||
|
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
label.setAttribute('x', x + barWidth / 2);
|
||||||
|
label.setAttribute('y', chartHeight + chartPadding.top + 15);
|
||||||
|
label.setAttribute('text-anchor', 'middle');
|
||||||
|
label.setAttribute('font-size', '10px');
|
||||||
|
label.setAttribute('fill', 'var(--text-secondary)');
|
||||||
|
label.textContent = user.username;
|
||||||
|
svg.appendChild(label);
|
||||||
|
|
||||||
|
// Rotate username if there are many users
|
||||||
|
if (chartData.length > 5) {
|
||||||
|
label.setAttribute('transform', `rotate(45, ${x + barWidth / 2}, ${chartHeight + chartPadding.top + 15})`);
|
||||||
|
label.setAttribute('text-anchor', 'start');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accuracy label on top of bar
|
||||||
|
const accuracyLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
accuracyLabel.setAttribute('x', x + barWidth / 2);
|
||||||
|
accuracyLabel.setAttribute('y', y - 5);
|
||||||
|
accuracyLabel.setAttribute('text-anchor', 'middle');
|
||||||
|
accuracyLabel.setAttribute('font-size', '10px');
|
||||||
|
accuracyLabel.setAttribute('fill', 'var(--text-primary)');
|
||||||
|
accuracyLabel.textContent = `${user.accuracy.toFixed(0)}%`;
|
||||||
|
svg.appendChild(accuracyLabel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show tooltip for bar charts
|
||||||
|
function showBarTooltip(event) {
|
||||||
|
// Get tooltip element or create one if it doesn't exist
|
||||||
|
let tooltip = document.querySelector('.chart-tooltip');
|
||||||
|
if (!tooltip) {
|
||||||
|
tooltip = document.createElement('div');
|
||||||
|
tooltip.className = 'chart-tooltip';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get data from the bar
|
||||||
|
const username = this.getAttribute('data-username');
|
||||||
|
const accuracy = this.getAttribute('data-accuracy');
|
||||||
|
|
||||||
|
// Format the content
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div><strong>User:</strong> ${username}</div>
|
||||||
|
<div><strong>Accuracy:</strong> ${accuracy}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Position the tooltip
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
tooltip.style.left = `${rect.left + rect.width / 2}px`;
|
||||||
|
tooltip.style.top = `${rect.top - tooltip.offsetHeight - 10}px`;
|
||||||
|
|
||||||
|
// Show the tooltip
|
||||||
|
tooltip.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render student breakdown table
|
||||||
|
function renderStudentBreakdownTable(data) {
|
||||||
|
const tableBody = document.getElementById('studentBreakdown');
|
||||||
|
if (!tableBody || !data) return;
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
// Get user stats
|
||||||
|
const userStats = data.userStats || {};
|
||||||
|
|
||||||
|
// If no data, show empty state
|
||||||
|
if (Object.keys(userStats).length === 0) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
cell.colSpan = 5;
|
||||||
|
cell.textContent = 'No student data available';
|
||||||
|
cell.style.textAlign = 'center';
|
||||||
|
row.appendChild(cell);
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a row for each user
|
||||||
|
Object.keys(userStats).forEach(username => {
|
||||||
|
const user = userStats[username];
|
||||||
|
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
// Username cell
|
||||||
|
const nameCell = document.createElement('td');
|
||||||
|
nameCell.textContent = username;
|
||||||
|
row.appendChild(nameCell);
|
||||||
|
|
||||||
|
// Sessions cell
|
||||||
|
const sessionsCell = document.createElement('td');
|
||||||
|
sessionsCell.textContent = user.totalSessions || 0;
|
||||||
|
row.appendChild(sessionsCell);
|
||||||
|
|
||||||
|
// Words cell
|
||||||
|
const wordsCell = document.createElement('td');
|
||||||
|
wordsCell.textContent = user.totalWords || 0;
|
||||||
|
row.appendChild(wordsCell);
|
||||||
|
|
||||||
|
// Accuracy cell
|
||||||
|
const accuracyCell = document.createElement('td');
|
||||||
|
const accuracy = user.accuracy || 0;
|
||||||
|
accuracyCell.textContent = `${accuracy.toFixed(1)}%`;
|
||||||
|
row.appendChild(accuracyCell);
|
||||||
|
|
||||||
|
// Trend sparkline cell
|
||||||
|
const trendCell = document.createElement('td');
|
||||||
|
const sparklineContainer = document.createElement('div');
|
||||||
|
sparklineContainer.className = 'sparkline';
|
||||||
|
trendCell.appendChild(sparklineContainer);
|
||||||
|
row.appendChild(trendCell);
|
||||||
|
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
|
||||||
|
// Create mini sparkline chart
|
||||||
|
createSparkline(sparklineContainer, user.trend || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mini sparkline chart
|
||||||
|
function createSparkline(container, trendData) {
|
||||||
|
if (!container || !trendData || trendData.length === 0) {
|
||||||
|
container.innerHTML = '-';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SVG
|
||||||
|
const width = container.clientWidth || 80;
|
||||||
|
const height = container.clientHeight || 24;
|
||||||
|
|
||||||
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', width);
|
||||||
|
svg.setAttribute('height', height);
|
||||||
|
container.appendChild(svg);
|
||||||
|
|
||||||
|
// Scale functions
|
||||||
|
const xScale = index => (index / (trendData.length - 1)) * width;
|
||||||
|
const yScale = accuracy => height - (accuracy / 100) * height;
|
||||||
|
|
||||||
|
// Create path for the line
|
||||||
|
const pathData = trendData.map((point, i) => {
|
||||||
|
return `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(point.accuracy || 0)}`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
// Add the path
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.setAttribute('d', pathData);
|
||||||
|
path.setAttribute('stroke', 'var(--accent)');
|
||||||
|
path.setAttribute('stroke-width', '2');
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
svg.appendChild(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render recent activity feed
|
||||||
|
function renderRecentActivityFeed(data) {
|
||||||
|
const container = document.getElementById('recentActivityList');
|
||||||
|
if (!container || !data) return;
|
||||||
|
|
||||||
|
// Clear previous content
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Collect recent results from all users
|
||||||
|
const allRecentResults = [];
|
||||||
|
|
||||||
|
Object.keys(data.userStats || {}).forEach(username => {
|
||||||
|
const user = data.userStats[username];
|
||||||
|
(user.recentResults || []).forEach(result => {
|
||||||
|
allRecentResults.push({
|
||||||
|
username,
|
||||||
|
date: new Date(result.date),
|
||||||
|
score: result.score,
|
||||||
|
total: result.total
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by date (newest first)
|
||||||
|
allRecentResults.sort((a, b) => b.date - a.date);
|
||||||
|
|
||||||
|
// Limit to 10 most recent
|
||||||
|
const recentResults = allRecentResults.slice(0, 10);
|
||||||
|
|
||||||
|
// If no data, show empty state
|
||||||
|
if (recentResults.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No recent activity data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create activity items
|
||||||
|
recentResults.forEach(activity => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'activity-item';
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.innerHTML = `
|
||||||
|
<span class="activity-user">${activity.username}</span>
|
||||||
|
completed a session with score
|
||||||
|
<strong>${activity.score}/${activity.total}</strong>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const time = document.createElement('div');
|
||||||
|
time.className = 'activity-time';
|
||||||
|
time.textContent = formatTimeAgo(activity.date);
|
||||||
|
|
||||||
|
item.appendChild(info);
|
||||||
|
item.appendChild(time);
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time ago (e.g., "2 hours ago")
|
||||||
|
function formatTimeAgo(date) {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - date;
|
||||||
|
const diffSecs = Math.floor(diffMs / 1000);
|
||||||
|
const diffMins = Math.floor(diffSecs / 60);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffDays > 0) {
|
||||||
|
return diffDays === 1 ? '1 day ago' : `${diffDays} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffHours > 0) {
|
||||||
|
return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMins > 0) {
|
||||||
|
return diffMins === 1 ? '1 minute ago' : `${diffMins} minutes ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
@@ -384,6 +384,169 @@ app.get('/getResults', (req, res) => {
|
|||||||
res.json(results);
|
res.json(results);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 📈 Enhanced Analytics API endpoint
|
||||||
|
app.get('/getAnalytics', (req, res) => {
|
||||||
|
try {
|
||||||
|
const results = readJsonSafe(path.join(DATA_DIR, files.results), {});
|
||||||
|
|
||||||
|
// Prepare analytics data structure
|
||||||
|
const analytics = {
|
||||||
|
summary: {
|
||||||
|
totalSessions: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
overallAccuracy: 0,
|
||||||
|
averageScore: 0,
|
||||||
|
mostMissedWords: [],
|
||||||
|
activityByDate: {}
|
||||||
|
},
|
||||||
|
userStats: {},
|
||||||
|
wordStats: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process all results data
|
||||||
|
Object.keys(results).forEach(username => {
|
||||||
|
// Skip if user has no results
|
||||||
|
if (!Array.isArray(results[username]) || results[username].length === 0) return;
|
||||||
|
|
||||||
|
// Initialize user stats if not already present
|
||||||
|
if (!analytics.userStats[username]) {
|
||||||
|
analytics.userStats[username] = {
|
||||||
|
totalSessions: 0,
|
||||||
|
totalWords: 0,
|
||||||
|
correctWords: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
averageScore: 0,
|
||||||
|
trend: [], // For progress over time
|
||||||
|
recentResults: [],
|
||||||
|
problemWords: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each session for this user
|
||||||
|
results[username].forEach(session => {
|
||||||
|
// Skip invalid sessions
|
||||||
|
if (!session || !session.answers || !Array.isArray(session.answers)) return;
|
||||||
|
|
||||||
|
// Update summary counts
|
||||||
|
analytics.summary.totalSessions++;
|
||||||
|
analytics.userStats[username].totalSessions++;
|
||||||
|
|
||||||
|
// Process session date for activity tracking
|
||||||
|
const sessionDate = new Date(session.timestamp || Date.now()).toISOString().split('T')[0];
|
||||||
|
analytics.summary.activityByDate[sessionDate] = (analytics.summary.activityByDate[sessionDate] || 0) + 1;
|
||||||
|
|
||||||
|
// Collect score for trending data
|
||||||
|
if (typeof session.score === 'number' && session.answers.length > 0) {
|
||||||
|
const accuracy = session.score / session.answers.length;
|
||||||
|
analytics.userStats[username].trend.push({
|
||||||
|
date: sessionDate,
|
||||||
|
accuracy: accuracy * 100,
|
||||||
|
score: session.score,
|
||||||
|
total: session.answers.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only most recent 10 sessions for the trend data
|
||||||
|
if (analytics.userStats[username].trend.length > 10) {
|
||||||
|
analytics.userStats[username].trend = analytics.userStats[username].trend.slice(-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the 5 most recent results for display
|
||||||
|
if (analytics.userStats[username].recentResults.length < 5) {
|
||||||
|
analytics.userStats[username].recentResults.push({
|
||||||
|
date: new Date(session.timestamp || Date.now()).toLocaleString(),
|
||||||
|
score: session.score,
|
||||||
|
total: session.answers.length,
|
||||||
|
completed: session.completed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process individual words in the session
|
||||||
|
session.answers.forEach(answer => {
|
||||||
|
// Skip invalid answers
|
||||||
|
if (!answer || typeof answer.word !== 'string') return;
|
||||||
|
|
||||||
|
// Update word stats
|
||||||
|
const word = answer.word.toLowerCase();
|
||||||
|
|
||||||
|
if (!analytics.wordStats[word]) {
|
||||||
|
analytics.wordStats[word] = {
|
||||||
|
attempts: 0,
|
||||||
|
correct: 0,
|
||||||
|
incorrect: 0,
|
||||||
|
accuracy: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
analytics.wordStats[word].attempts++;
|
||||||
|
analytics.summary.totalWords++;
|
||||||
|
analytics.userStats[username].totalWords++;
|
||||||
|
|
||||||
|
if (answer.correct) {
|
||||||
|
analytics.wordStats[word].correct++;
|
||||||
|
analytics.summary.correctWords++;
|
||||||
|
analytics.userStats[username].correctWords++;
|
||||||
|
} else {
|
||||||
|
analytics.wordStats[word].incorrect++;
|
||||||
|
|
||||||
|
// Track problem words for this user
|
||||||
|
const problemWordIndex = analytics.userStats[username].problemWords.findIndex(pw => pw.word === word);
|
||||||
|
|
||||||
|
if (problemWordIndex >= 0) {
|
||||||
|
analytics.userStats[username].problemWords[problemWordIndex].count++;
|
||||||
|
} else {
|
||||||
|
analytics.userStats[username].problemWords.push({
|
||||||
|
word,
|
||||||
|
count: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate accuracy for this word
|
||||||
|
analytics.wordStats[word].accuracy =
|
||||||
|
analytics.wordStats[word].correct / analytics.wordStats[word].attempts * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate user accuracy and average score
|
||||||
|
analytics.userStats[username].accuracy =
|
||||||
|
analytics.userStats[username].correctWords / analytics.userStats[username].totalWords * 100 || 0;
|
||||||
|
analytics.userStats[username].averageScore =
|
||||||
|
analytics.userStats[username].correctWords / analytics.userStats[username].totalSessions || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort problem words by count (descending)
|
||||||
|
analytics.userStats[username].problemWords.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
|
// Keep only top 5 problem words
|
||||||
|
analytics.userStats[username].problemWords = analytics.userStats[username].problemWords.slice(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate overall metrics
|
||||||
|
analytics.summary.overallAccuracy =
|
||||||
|
analytics.summary.correctWords / analytics.summary.totalWords * 100 || 0;
|
||||||
|
analytics.summary.averageScore =
|
||||||
|
analytics.summary.correctWords / analytics.summary.totalSessions || 0;
|
||||||
|
|
||||||
|
// Find most missed words (top 5)
|
||||||
|
const missedWords = Object.keys(analytics.wordStats)
|
||||||
|
.map(word => ({
|
||||||
|
word,
|
||||||
|
missCount: analytics.wordStats[word].incorrect
|
||||||
|
}))
|
||||||
|
.filter(item => item.missCount > 0)
|
||||||
|
.sort((a, b) => b.missCount - a.missCount)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
analytics.summary.mostMissedWords = missedWords;
|
||||||
|
|
||||||
|
res.json(analytics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating analytics:', error);
|
||||||
|
res.status(500).send('Error generating analytics data');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/saveResults', (req, res) => {
|
app.post('/saveResults', (req, res) => {
|
||||||
const { username, result } = req.body;
|
const { username, result } = req.body;
|
||||||
if (
|
if (
|
||||||
|
|||||||
Reference in new issue
Block a user