-
Summary Statistics
-
- - Average Score: –
- - Total Sessions: –
- - Most Missed Word: –
-
+
+
+
-
-
Student Breakdown
-
+
+
Overall Accuracy
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Most Challenging Words
+
+
+
+
+
+
User Performance Comparison
+
+
+
+
+
+
Student Performance
+
+
+
+
+ | Student |
+ Sessions |
+ Words |
+ Accuracy |
+ Trend |
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/admin.js b/public/js/admin.js
index 3bb2307..8bc51df 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -457,58 +457,35 @@ async function loadUserDropdowns() {
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() {
- const summary = document.getElementById("analyticsSummary");
- const breakdown = document.getElementById("studentBreakdown");
- summary.innerHTML = "
Loading...";
- breakdown.innerHTML = "";
-
- try {
- const res = await fetch('/getResults');
- const data = await res.json();
-
- let totalScore = 0, sessionCount = 0;
- const missMap = {};
-
- 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 = `
${username}: ${score}/${answers.length} correct`;
- breakdown.appendChild(li);
+ // 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 breakdown = document.getElementById("studentBreakdown");
+
+ if (summary) summary.innerHTML = "
Loading...";
+ if (breakdown) breakdown.innerHTML = "";
+
+ const res = await fetch('/getResults');
+ const data = await res.json();
+
+ // Legacy implementation...
+ console.log("Analytics data loaded:", Object.keys(data).length, "users");
+ } catch (err) {
+ console.error("📉 Failed to refresh analytics:", err);
+ const summary = document.getElementById("analyticsSummary");
+ if (summary) summary.innerHTML = '
Error loading analytics.';
}
-
- 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 = `
-
Average Score: ${avgScore}
-
Total Completed Sessions: ${sessionCount}
-
Most Missed Word: ${mostMissed} (${highestMisses} misses)
- `;
- } catch (err) {
- console.error("📉 Failed to refresh analytics:", err);
- summary.innerHTML = '
Error loading analytics.';
}
}
+
async function loadBadgeViewer() {
const res = await fetch('/getBadges');
const badgeData = await res.json();
diff --git a/public/js/analytics.js b/public/js/analytics.js
new file mode 100644
index 0000000..a5b704d
--- /dev/null
+++ b/public/js/analytics.js
@@ -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 = '
';
+
+ // 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 = '
| ' + loadingTemplate + ' |
';
+ 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 = '
No progress data available
';
+ 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 = `
+
Date: ${new Date(date).toLocaleDateString()}
+
Accuracy: ${accuracy}
+ `;
+
+ // 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 = '
No activity data available
';
+ 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 = `
+
Date: ${date}
+
Sessions: ${count}
+ `;
+
+ // 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 = '
No problem words data available
';
+ 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 = '
No comparison data available
';
+ 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 = `
+
User: ${username}
+
Accuracy: ${accuracy}
+ `;
+
+ // 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 = '
No recent activity data available
';
+ return;
+ }
+
+ // Create activity items
+ recentResults.forEach(activity => {
+ const item = document.createElement('div');
+ item.className = 'activity-item';
+
+ const info = document.createElement('div');
+ info.innerHTML = `
+
${activity.username}
+ completed a session with score
+
${activity.score}/${activity.total}
+ `;
+
+ 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';
+}
diff --git a/server.js b/server.js
index 8347eff..bbf670f 100644
--- a/server.js
+++ b/server.js
@@ -384,6 +384,169 @@ app.get('/getResults', (req, res) => {
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) => {
const { username, result } = req.body;
if (