From 10d1f88a58040e89b3d948ae1909e64241a6d773 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 14 Oct 2025 17:48:21 +0200 Subject: [PATCH] Add standard deviation display for predicted ratings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Calculate and store standard deviation during rating prediction - Add std_dev column to players database table - Display standard deviation tooltip on hover over predicted rating - Show rating range (±std_dev) tooltip on hover over current rating - Update tooltips dynamically when ratings are refreshed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- index.html | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++--- server.js | 47 ++++++++++--- 2 files changed, 225 insertions(+), 22 deletions(-) diff --git a/index.html b/index.html index cda76bf..8cb587a 100644 --- a/index.html +++ b/index.html @@ -196,6 +196,25 @@ font-weight: bold; color: #007bff; } + .std-dev-tooltip { + position: absolute; + background-color: rgba(0, 0, 0, 0.9); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 10000; + display: none; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.2); + font-weight: normal; + } + .predicted-value { + position: relative; + display: inline-block; + } .pdga-number { color: #6c757d; font-size: 14px; @@ -582,18 +601,20 @@ #${player.pdgaNumber}
- ${player.rating || 'Click refresh'} + ${player.rating || 'Click refresh'}
${ratingChangeText}
+
${ratingChangeText}
- ${player.predictedRating || 'N/A'} + ${player.predictedRating || 'N/A'}
+
@@ -619,9 +640,63 @@ `; tableDiv.innerHTML = tableHTML; + + // Add hover listeners for predicted rating standard deviation tooltips + document.querySelectorAll('.predicted-value').forEach(span => { + const pdgaNumber = span.dataset.pdga; + const stdDev = span.dataset.stddev; + const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`); + + if (stdDev && tooltip) { + span.addEventListener('mouseenter', (e) => { + tooltip.textContent = `Standard Deviation: ±${stdDev}`; + tooltip.style.display = 'block'; + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + span.addEventListener('mousemove', (e) => { + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + span.addEventListener('mouseleave', () => { + tooltip.style.display = 'none'; + }); + } + }); + + // Add hover listeners for current rating range tooltips + document.querySelectorAll('.rating-value').forEach(span => { + const pdgaNumber = span.dataset.pdga; + const rating = parseInt(span.dataset.rating); + const stdDev = parseInt(span.dataset.stddev); + const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); + + if (rating && stdDev && tooltip) { + const minRating = rating - stdDev; + const maxRating = rating + stdDev; + + span.addEventListener('mouseenter', (e) => { + tooltip.textContent = `Rating Range: ${minRating} - ${maxRating} (±${stdDev})`; + tooltip.style.display = 'block'; + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + span.addEventListener('mousemove', (e) => { + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + span.addEventListener('mouseleave', () => { + tooltip.style.display = 'none'; + }); + } + }); } - - + + async function togglePlayerHistory(pdgaNumber) { const historyRow = document.getElementById(`history-${pdgaNumber}`); const chartContainer = document.getElementById(`chart-${pdgaNumber}`); @@ -846,11 +921,48 @@ const ratingChangeClass = data.player.ratingChange > 0 ? 'positive' : data.player.ratingChange < 0 ? 'negative' : 'neutral'; - const refreshSection = ratingCell.querySelector('.refresh-section'); - refreshSection.innerHTML = `${data.player.rating || 'N/A'} `; + // Update rating value + const ratingValue = ratingCell.querySelector('.rating-value'); + if (ratingValue) { + ratingValue.textContent = data.player.rating || 'N/A'; + ratingValue.dataset.rating = data.player.rating || ''; + // stdDev stays the same - only updates with predicted rating refresh + + // Re-attach tooltip listeners if we have both rating and stdDev + const stdDev = parseInt(ratingValue.dataset.stddev); + const rating = parseInt(data.player.rating); + const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); + + if (rating && stdDev && tooltip) { + // Remove old listeners by cloning and replacing + const newRatingValue = ratingValue.cloneNode(true); + ratingValue.parentNode.replaceChild(newRatingValue, ratingValue); + + const minRating = rating - stdDev; + const maxRating = rating + stdDev; + + // Add new listeners + newRatingValue.addEventListener('mouseenter', (e) => { + tooltip.textContent = `Rating Range: ${minRating} - ${maxRating} (±${stdDev})`; + tooltip.style.display = 'block'; + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + newRatingValue.addEventListener('mousemove', (e) => { + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + newRatingValue.addEventListener('mouseleave', () => { + tooltip.style.display = 'none'; + }); + } + } + if (ratingChangeCell) ratingChangeCell.textContent = ratingChangeText; if (ratingChangeCell) ratingChangeCell.className = `rating-change ${ratingChangeClass} mobile-hide`; - + // Update mobile rating change const mobileChange = ratingCell.querySelector('.mobile-only.rating-change'); if (mobileChange) { @@ -887,13 +999,79 @@ if (data.debugLog) { cachedDebugInfo[pdgaNumber] = data.debugLog; } - + // Update predicted rating if the element exists const predictedCell = document.getElementById(`predicted-${pdgaNumber}`); if (predictedCell) { - const refreshSection = predictedCell.querySelector('.refresh-section'); - if (refreshSection && refreshSection.firstChild) { - refreshSection.firstChild.textContent = data.predictedRating || 'N/A'; + const predictedValue = predictedCell.querySelector('.predicted-value'); + if (predictedValue) { + predictedValue.textContent = data.predictedRating || 'N/A'; + // Update data attribute for tooltip + predictedValue.dataset.stddev = data.stdDev || ''; + + // Re-attach tooltip listeners + const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`); + if (data.stdDev && tooltip) { + // Remove old listeners by cloning and replacing + const newPredictedValue = predictedValue.cloneNode(true); + predictedValue.parentNode.replaceChild(newPredictedValue, predictedValue); + + // Add new listeners + newPredictedValue.addEventListener('mouseenter', (e) => { + tooltip.textContent = `Standard Deviation: ±${data.stdDev}`; + tooltip.style.display = 'block'; + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + newPredictedValue.addEventListener('mousemove', (e) => { + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + }); + + newPredictedValue.addEventListener('mouseleave', () => { + tooltip.style.display = 'none'; + }); + } + } + } + + // Also update the rating value's stddev attribute and tooltip + const row = document.getElementById(`row-${pdgaNumber}`); + const ratingCell = row.querySelector('.rating'); + const ratingValue = ratingCell.querySelector('.rating-value'); + if (ratingValue && data.stdDev) { + ratingValue.dataset.stddev = data.stdDev; + + // Re-attach rating tooltip listeners if we have both rating and stdDev + const rating = parseInt(ratingValue.dataset.rating); + const stdDev = parseInt(data.stdDev); + const ratingTooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); + + if (rating && stdDev && ratingTooltip) { + // Remove old listeners by cloning and replacing + const newRatingValue = ratingValue.cloneNode(true); + ratingValue.parentNode.replaceChild(newRatingValue, ratingValue); + + const minRating = rating - stdDev; + const maxRating = rating + stdDev; + + // Add new listeners + newRatingValue.addEventListener('mouseenter', (e) => { + ratingTooltip.textContent = `Rating Range: ${minRating} - ${maxRating} (±${stdDev})`; + ratingTooltip.style.display = 'block'; + ratingTooltip.style.left = `${e.clientX + 15}px`; + ratingTooltip.style.top = `${e.clientY - 35}px`; + }); + + newRatingValue.addEventListener('mousemove', (e) => { + ratingTooltip.style.left = `${e.clientX + 15}px`; + ratingTooltip.style.top = `${e.clientY - 35}px`; + }); + + newRatingValue.addEventListener('mouseleave', () => { + ratingTooltip.style.display = 'none'; + }); } } diff --git a/server.js b/server.js index 02b4efe..e8b4a1c 100644 --- a/server.js +++ b/server.js @@ -54,6 +54,7 @@ function initializeDatabase() { const hasLastRoundUpdate = columns.some(col => col.name === 'last_round_update'); const hasPredictedRating = columns.some(col => col.name === 'predicted_rating'); + const hasStdDev = columns.some(col => col.name === 'std_dev'); if (!hasLastRoundUpdate) { console.log('Adding last_round_update column to players table...'); @@ -80,6 +81,19 @@ function initializeDatabase() { } }); } + + if (!hasStdDev) { + console.log('Adding std_dev column to players table...'); + db.run(` + ALTER TABLE players ADD COLUMN std_dev INTEGER DEFAULT NULL + `, (err) => { + if (err) { + console.error('Error adding std_dev column:', err.message); + } else { + console.log('Successfully added std_dev column'); + } + }); + } }); }); @@ -533,8 +547,12 @@ async function getPlayerDataFromDB(pdgaNumber) { // Use stored predicted_rating if available, otherwise calculate it from round history let predictedRating = cachedPlayer.predicted_rating; + let stdDev = cachedPlayer.std_dev; if (!predictedRating || predictedRating === 0) { predictedRating = await getPredictedRatingFromDB(pdgaNumber); + // After calculation, re-fetch to get the updated std_dev + const updatedPlayer = await getPlayerFromDB(pdgaNumber); + stdDev = updatedPlayer?.std_dev; } return { @@ -542,7 +560,8 @@ async function getPlayerDataFromDB(pdgaNumber) { name: cachedPlayer.name, rating: cachedPlayer.current_rating, ratingChange: cachedPlayer.rating_change, - predictedRating: predictedRating > 0 ? predictedRating : null + predictedRating: predictedRating > 0 ? predictedRating : null, + stdDev: stdDev > 0 ? stdDev : null }; } return null; // No data in DB @@ -653,7 +672,7 @@ async function getPredictedRatingFromDB(pdgaNumber) { const result = calculatePredictedRating(roundRatings); // Save the calculated prediction to database - await savePredictedRatingToDB(pdgaNumber, result.rating); + await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev); return result.rating; } @@ -664,11 +683,11 @@ async function getPredictedRatingFromDB(pdgaNumber) { } } -function savePredictedRatingToDB(pdgaNumber, predictedRating) { +function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) { return new Promise((resolve, reject) => { db.run( - 'UPDATE players SET predicted_rating = ? WHERE pdga_number = ?', - [predictedRating, pdgaNumber], + 'UPDATE players SET predicted_rating = ?, std_dev = ? WHERE pdga_number = ?', + [predictedRating, stdDev, pdgaNumber], function(err) { if (err) reject(err); else resolve(); @@ -1483,15 +1502,19 @@ function calculatePredictedRating(roundRatings) { const sum = weightedRatings.reduce((sum, r) => sum + r, 0); const average = sum / weightedRatings.length; const finalRating = Math.round(average); - + + // Calculate standard deviation of the weighted ratings + const stdDev = calculateStandardDeviation(weightedRatings); + debugLog.push('🎯 FINAL CALCULATION:'); debugLog.push(` Sum: ${sum}`); debugLog.push(` Count: ${weightedRatings.length}`); debugLog.push(` Average: ${average.toFixed(1)}`); + debugLog.push(` Standard Deviation: ${stdDev.toFixed(1)}`); debugLog.push(` Final Rating: ${finalRating}`); debugLog.push('=== END PDGA CALCULATION ==='); - - return { rating: finalRating, debugLog }; + + return { rating: finalRating, stdDev: Math.round(stdDev), debugLog }; } function calculateStandardDeviation(ratings) { @@ -2773,7 +2796,7 @@ app.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => { const result = calculatePredictedRating(roundsForPrediction); // Save the predicted rating to database for persistence - await savePredictedRatingToDB(pdgaNumber, result.rating); + await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev); // Count official vs new rounds const officialCount = allRounds.filter(r => r.source === 'official').length; @@ -2782,6 +2805,7 @@ app.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => { res.json({ success: true, predictedRating: result.rating, + stdDev: result.stdDev, debugLog: result.debugLog, totalRounds: roundsForPrediction.length, officialRounds: officialCount, @@ -3319,10 +3343,11 @@ app.post('/api/predicted-rating/:pdgaNumber', async (req, res) => { })); const result = calculatePredictedRating(roundRatings); - - res.json({ + + res.json({ pdgaNumber: parseInt(pdgaNumber), predictedRating: result.rating, + stdDev: result.stdDev, debugLog: result.debugLog }); } catch (error) {