From 27af564a416d05a1ae2387af35547ef6517e1e1d Mon Sep 17 00:00:00 2001 From: "nate@necloud.us" Date: Wed, 13 Aug 2025 11:45:19 -0400 Subject: [PATCH] Implement enhanced Analytics Dashboard with visualizations and detailed metrics --- analytics-endpoint.js | 164 ++++++++ public/analytics.css | 238 +++++++++++ public/index.html | 99 ++++- public/js/admin.js | 73 ++-- public/js/analytics.js | 905 +++++++++++++++++++++++++++++++++++++++++ server.js | 163 ++++++++ 6 files changed, 1582 insertions(+), 60 deletions(-) create mode 100644 analytics-endpoint.js create mode 100644 public/analytics.css create mode 100644 public/js/analytics.js diff --git a/analytics-endpoint.js b/analytics-endpoint.js new file mode 100644 index 0000000..2b5526d --- /dev/null +++ b/analytics-endpoint.js @@ -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'); + } +}); diff --git a/public/analytics.css b/public/analytics.css new file mode 100644 index 0000000..6dfc577 --- /dev/null +++ b/public/analytics.css @@ -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); +} diff --git a/public/index.html b/public/index.html index eadfb96..dc82d9f 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,7 @@ + @@ -20,6 +21,7 @@ + @@ -163,22 +165,95 @@

📊 Performance Analytics

- +
+ + + +
-
-
-

Summary Statistics

-
    -
  • Average Score: –
  • -
  • Total Sessions: –
  • -
  • Most Missed Word: –
  • -
+
+ +
+
Total Sessions
+
-
+
-
-

Student Breakdown

-
    +
    +
    Overall Accuracy
    +
    -
    +
    +
    + +
    +
    Total Words
    +
    -
    +
    + +
    +
    Average Score
    +
    -
    +
    + + +
    +

    Progress Over Time

    +
    +
    + + +
    +

    Activity Calendar

    +
    +
    + + +
    +

    Most Challenging Words

    +
    +
    +
    +
    + + +
    +

    User Performance Comparison

    +
    +
    + + +
    +

    Student Performance

    +
    + + + + + + + + + + + + +
    StudentSessionsWordsAccuracyTrend
    +
    +
    + + +
    +

    Recent Activity

    +
    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 (