Add standard deviation display for predicted ratings

- 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 <noreply@anthropic.com>
This commit is contained in:
Samuel Enocsson
2025-10-14 17:48:21 +02:00
parent d46f045815
commit 10d1f88a58
2 changed files with 225 additions and 22 deletions
+189 -11
View File
@@ -196,6 +196,25 @@
font-weight: bold; font-weight: bold;
color: #007bff; 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 { .pdga-number {
color: #6c757d; color: #6c757d;
font-size: 14px; font-size: 14px;
@@ -582,18 +601,20 @@
<td class="pdga-number mobile-hide">#${player.pdgaNumber}</td> <td class="pdga-number mobile-hide">#${player.pdgaNumber}</td>
<td class="rating"> <td class="rating">
<div class="refresh-section"> <div class="refresh-section">
${player.rating || '<span style="color: #999; font-style: italic;">Click refresh</span>'} <span class="rating-value" data-rating="${player.rating || ''}" data-stddev="${player.stdDev || ''}" data-pdga="${player.pdgaNumber}" style="cursor: help;">${player.rating || '<span style="color: #999; font-style: italic;">Click refresh</span>'}</span>
<i class="fas fa-sync-alt refresh-icon" onclick="refreshPlayer(${player.pdgaNumber})" title="Refresh player data"></i> <i class="fas fa-sync-alt refresh-icon" onclick="refreshPlayer(${player.pdgaNumber})" title="Refresh player data"></i>
</div> </div>
<div class="mobile-only rating-change ${ratingChangeClass}" style="font-size: 11px; margin-top: 2px;">${ratingChangeText}</div> <div class="mobile-only rating-change ${ratingChangeClass}" style="font-size: 11px; margin-top: 2px;">${ratingChangeText}</div>
<div class="std-dev-tooltip" id="tooltip-rating-${player.pdgaNumber}"></div>
</td> </td>
<td class="rating-change ${ratingChangeClass} mobile-hide">${ratingChangeText}</td> <td class="rating-change ${ratingChangeClass} mobile-hide">${ratingChangeText}</td>
<td class="predicted-rating mobile-hide" id="predicted-${player.pdgaNumber}"> <td class="predicted-rating mobile-hide" id="predicted-${player.pdgaNumber}">
<div class="refresh-section"> <div class="refresh-section">
${player.predictedRating || 'N/A'} <span class="predicted-value" data-stddev="${player.stdDev || ''}" data-pdga="${player.pdgaNumber}" style="cursor: help;">${player.predictedRating || 'N/A'}</span>
<i class="fas fa-question-circle debug-icon" onclick="showDebugInfo(${player.pdgaNumber})" title="Show calculation details" style="margin-left: 5px; color: #6c757d; cursor: pointer; opacity: 0.6;"></i> <i class="fas fa-question-circle debug-icon" onclick="showDebugInfo(${player.pdgaNumber})" title="Show calculation details" style="margin-left: 5px; color: #6c757d; cursor: pointer; opacity: 0.6;"></i>
<i class="fas fa-sync-alt refresh-icon" onclick="refreshRoundHistory(${player.pdgaNumber})" title="Refresh prediction data"></i> <i class="fas fa-sync-alt refresh-icon" onclick="refreshRoundHistory(${player.pdgaNumber})" title="Refresh prediction data"></i>
</div> </div>
<div class="std-dev-tooltip" id="tooltip-stddev-${player.pdgaNumber}"></div>
</td> </td>
</tr> </tr>
<tr id="history-${player.pdgaNumber}" class="expanded-content"> <tr id="history-${player.pdgaNumber}" class="expanded-content">
@@ -619,9 +640,63 @@
`; `;
tableDiv.innerHTML = tableHTML; 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) { async function togglePlayerHistory(pdgaNumber) {
const historyRow = document.getElementById(`history-${pdgaNumber}`); const historyRow = document.getElementById(`history-${pdgaNumber}`);
const chartContainer = document.getElementById(`chart-${pdgaNumber}`); const chartContainer = document.getElementById(`chart-${pdgaNumber}`);
@@ -846,11 +921,48 @@
const ratingChangeClass = data.player.ratingChange > 0 ? 'positive' : const ratingChangeClass = data.player.ratingChange > 0 ? 'positive' :
data.player.ratingChange < 0 ? 'negative' : 'neutral'; data.player.ratingChange < 0 ? 'negative' : 'neutral';
const refreshSection = ratingCell.querySelector('.refresh-section'); // Update rating value
refreshSection.innerHTML = `${data.player.rating || 'N/A'} <i class="fas fa-sync-alt refresh-icon" onclick="refreshPlayer(${pdgaNumber})" title="Refresh player data"></i>`; 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.textContent = ratingChangeText;
if (ratingChangeCell) ratingChangeCell.className = `rating-change ${ratingChangeClass} mobile-hide`; if (ratingChangeCell) ratingChangeCell.className = `rating-change ${ratingChangeClass} mobile-hide`;
// Update mobile rating change // Update mobile rating change
const mobileChange = ratingCell.querySelector('.mobile-only.rating-change'); const mobileChange = ratingCell.querySelector('.mobile-only.rating-change');
if (mobileChange) { if (mobileChange) {
@@ -887,13 +999,79 @@
if (data.debugLog) { if (data.debugLog) {
cachedDebugInfo[pdgaNumber] = data.debugLog; cachedDebugInfo[pdgaNumber] = data.debugLog;
} }
// Update predicted rating if the element exists // Update predicted rating if the element exists
const predictedCell = document.getElementById(`predicted-${pdgaNumber}`); const predictedCell = document.getElementById(`predicted-${pdgaNumber}`);
if (predictedCell) { if (predictedCell) {
const refreshSection = predictedCell.querySelector('.refresh-section'); const predictedValue = predictedCell.querySelector('.predicted-value');
if (refreshSection && refreshSection.firstChild) { if (predictedValue) {
refreshSection.firstChild.textContent = data.predictedRating || 'N/A'; 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';
});
} }
} }
+36 -11
View File
@@ -54,6 +54,7 @@ function initializeDatabase() {
const hasLastRoundUpdate = columns.some(col => col.name === 'last_round_update'); const hasLastRoundUpdate = columns.some(col => col.name === 'last_round_update');
const hasPredictedRating = columns.some(col => col.name === 'predicted_rating'); const hasPredictedRating = columns.some(col => col.name === 'predicted_rating');
const hasStdDev = columns.some(col => col.name === 'std_dev');
if (!hasLastRoundUpdate) { if (!hasLastRoundUpdate) {
console.log('Adding last_round_update column to players table...'); 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 // Use stored predicted_rating if available, otherwise calculate it from round history
let predictedRating = cachedPlayer.predicted_rating; let predictedRating = cachedPlayer.predicted_rating;
let stdDev = cachedPlayer.std_dev;
if (!predictedRating || predictedRating === 0) { if (!predictedRating || predictedRating === 0) {
predictedRating = await getPredictedRatingFromDB(pdgaNumber); predictedRating = await getPredictedRatingFromDB(pdgaNumber);
// After calculation, re-fetch to get the updated std_dev
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
stdDev = updatedPlayer?.std_dev;
} }
return { return {
@@ -542,7 +560,8 @@ async function getPlayerDataFromDB(pdgaNumber) {
name: cachedPlayer.name, name: cachedPlayer.name,
rating: cachedPlayer.current_rating, rating: cachedPlayer.current_rating,
ratingChange: cachedPlayer.rating_change, 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 return null; // No data in DB
@@ -653,7 +672,7 @@ async function getPredictedRatingFromDB(pdgaNumber) {
const result = calculatePredictedRating(roundRatings); const result = calculatePredictedRating(roundRatings);
// Save the calculated prediction to database // Save the calculated prediction to database
await savePredictedRatingToDB(pdgaNumber, result.rating); await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
return result.rating; 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) => { return new Promise((resolve, reject) => {
db.run( db.run(
'UPDATE players SET predicted_rating = ? WHERE pdga_number = ?', 'UPDATE players SET predicted_rating = ?, std_dev = ? WHERE pdga_number = ?',
[predictedRating, pdgaNumber], [predictedRating, stdDev, pdgaNumber],
function(err) { function(err) {
if (err) reject(err); if (err) reject(err);
else resolve(); else resolve();
@@ -1483,15 +1502,19 @@ function calculatePredictedRating(roundRatings) {
const sum = weightedRatings.reduce((sum, r) => sum + r, 0); const sum = weightedRatings.reduce((sum, r) => sum + r, 0);
const average = sum / weightedRatings.length; const average = sum / weightedRatings.length;
const finalRating = Math.round(average); const finalRating = Math.round(average);
// Calculate standard deviation of the weighted ratings
const stdDev = calculateStandardDeviation(weightedRatings);
debugLog.push('🎯 FINAL CALCULATION:'); debugLog.push('🎯 FINAL CALCULATION:');
debugLog.push(` Sum: ${sum}`); debugLog.push(` Sum: ${sum}`);
debugLog.push(` Count: ${weightedRatings.length}`); debugLog.push(` Count: ${weightedRatings.length}`);
debugLog.push(` Average: ${average.toFixed(1)}`); debugLog.push(` Average: ${average.toFixed(1)}`);
debugLog.push(` Standard Deviation: ${stdDev.toFixed(1)}`);
debugLog.push(` Final Rating: ${finalRating}`); debugLog.push(` Final Rating: ${finalRating}`);
debugLog.push('=== END PDGA CALCULATION ==='); debugLog.push('=== END PDGA CALCULATION ===');
return { rating: finalRating, debugLog }; return { rating: finalRating, stdDev: Math.round(stdDev), debugLog };
} }
function calculateStandardDeviation(ratings) { function calculateStandardDeviation(ratings) {
@@ -2773,7 +2796,7 @@ app.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
const result = calculatePredictedRating(roundsForPrediction); const result = calculatePredictedRating(roundsForPrediction);
// Save the predicted rating to database for persistence // 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 // Count official vs new rounds
const officialCount = allRounds.filter(r => r.source === 'official').length; 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({ res.json({
success: true, success: true,
predictedRating: result.rating, predictedRating: result.rating,
stdDev: result.stdDev,
debugLog: result.debugLog, debugLog: result.debugLog,
totalRounds: roundsForPrediction.length, totalRounds: roundsForPrediction.length,
officialRounds: officialCount, officialRounds: officialCount,
@@ -3319,10 +3343,11 @@ app.post('/api/predicted-rating/:pdgaNumber', async (req, res) => {
})); }));
const result = calculatePredictedRating(roundRatings); const result = calculatePredictedRating(roundRatings);
res.json({ res.json({
pdgaNumber: parseInt(pdgaNumber), pdgaNumber: parseInt(pdgaNumber),
predictedRating: result.rating, predictedRating: result.rating,
stdDev: result.stdDev,
debugLog: result.debugLog debugLog: result.debugLog
}); });
} catch (error) { } catch (error) {