Enhance prediction system with multi-day tournament support and auto-calculation
- Fix parseDate function to handle multi-day tournament formats (e.g., "2-Sep to 3-Sep-2023") - Integrate PDGA update date simulation using 2nd Tuesday cutoffs for accurate predictions - Calculate and display predictions automatically from database on page load - Update rating calculation to use proper PDGA timing windows (12/24 months before update date) - Improve date parsing regex to correctly extract start dates from tournament ranges - Include updated player list in pdga-numbers.txt 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+294
-74
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PDGA Ratings</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -211,33 +212,6 @@
|
||||
.neutral {
|
||||
color: #6c757d;
|
||||
}
|
||||
.calc-btn {
|
||||
background-color: #17a2b8;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
min-height: 32px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.calc-btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
min-height: 36px;
|
||||
min-width: 70px;
|
||||
}
|
||||
}
|
||||
.calc-btn:hover {
|
||||
background-color: #138496;
|
||||
}
|
||||
.calc-btn:disabled {
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rating-change {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
@@ -249,13 +223,44 @@
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.refresh-icon {
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: #6c757d;
|
||||
transition: all 0.2s ease;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.refresh-icon:hover {
|
||||
opacity: 1;
|
||||
color: #007bff;
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.refresh-icon.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
color: #007bff;
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.refresh-section {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>PDGA Player Ratings</h1>
|
||||
<div style="position: absolute; top: 10px; right: 15px;">
|
||||
<a href="#" onclick="clearCache(); return false;" style="color: #ccc; font-size: 10px; text-decoration: none; opacity: 0.3;" title="Clear cache">⚙</a>
|
||||
<a href="#" onclick="loadAllPlayers(); return false;" style="color: #007bff; font-size: 11px; text-decoration: none; margin-right: 10px;" title="Load all player data" id="load-all-btn">Load All</a>
|
||||
<a href="#" onclick="clearCache(); return false;" style="color: #ccc; font-size: 12px; text-decoration: none; opacity: 0.3;" title="Clear cache"><i class="fas fa-cog"></i></a>
|
||||
</div>
|
||||
<div id="loading" class="loading" style="display: none;">Loading ratings...</div>
|
||||
<div id="progress-section" style="display: none;">
|
||||
@@ -333,7 +338,6 @@
|
||||
<th>Rating</th>
|
||||
<th class="mobile-hide">Change</th>
|
||||
<th class="mobile-hide">Predicted</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -359,21 +363,28 @@
|
||||
</td>
|
||||
<td class="pdga-number mobile-hide">#${player.pdgaNumber}</td>
|
||||
<td class="rating">
|
||||
${player.rating || 'N/A'}
|
||||
<div class="refresh-section">
|
||||
${player.rating || '<span style="color: #999; font-style: italic;">Click refresh</span>'}
|
||||
<i class="fas fa-sync-alt refresh-icon" onclick="refreshPlayer(${player.pdgaNumber})" title="Refresh player data"></i>
|
||||
</div>
|
||||
<div class="mobile-only rating-change ${ratingChangeClass}" style="font-size: 11px; margin-top: 2px;">${ratingChangeText}</div>
|
||||
</td>
|
||||
<td class="rating-change ${ratingChangeClass} mobile-hide">${ratingChangeText}</td>
|
||||
<td class="predicted-rating mobile-hide" id="predicted-${player.pdgaNumber}">
|
||||
${player.predictedRating || 'N/A'}
|
||||
</td>
|
||||
<td class="difference ${diffClass}" id="diff-${player.pdgaNumber}">
|
||||
${difference ? diffText :
|
||||
`<button class="calc-btn" onclick="event.stopPropagation(); calculatePredictedRating(${player.pdgaNumber})">Predict</button>`}
|
||||
<div class="refresh-section">
|
||||
${player.predictedRating || 'N/A'}
|
||||
<i class="fas fa-sync-alt refresh-icon" onclick="refreshRoundHistory(${player.pdgaNumber})" title="Refresh prediction data"></i>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="history-${player.pdgaNumber}" class="expanded-content">
|
||||
<td colspan="7" class="expanded-cell">
|
||||
<div class="chart-title">Rating History for ${player.name}</div>
|
||||
<td colspan="6" class="expanded-cell">
|
||||
<div class="chart-title">
|
||||
<div class="refresh-section">
|
||||
Rating History for ${player.name}
|
||||
<i class="fas fa-sync-alt refresh-icon" onclick="refreshRatingHistory(${player.pdgaNumber})" title="Refresh rating history"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container" id="chart-${player.pdgaNumber}" style="position: relative;">
|
||||
<div class="loading-chart">Click to load rating history...</div>
|
||||
</div>
|
||||
@@ -391,43 +402,6 @@
|
||||
tableDiv.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
async function calculatePredictedRating(pdgaNumber) {
|
||||
const button = document.querySelector(`#diff-${pdgaNumber} .calc-btn`);
|
||||
const predictedCell = document.getElementById(`predicted-${pdgaNumber}`);
|
||||
const diffCell = document.getElementById(`diff-${pdgaNumber}`);
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Calculating...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/predicted-rating/${pdgaNumber}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.predictedRating && data.predictedRating > 0) {
|
||||
predictedCell.textContent = data.predictedRating;
|
||||
|
||||
const currentRating = parseInt(document.querySelector(`#row-${pdgaNumber} .rating`).textContent);
|
||||
const difference = data.predictedRating - currentRating;
|
||||
const diffText = difference > 0 ? `+${difference}` : difference.toString();
|
||||
const diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral';
|
||||
|
||||
diffCell.className = `difference ${diffClass}`;
|
||||
diffCell.textContent = diffText;
|
||||
} else {
|
||||
predictedCell.textContent = 'N/A';
|
||||
diffCell.innerHTML = '<span style="color: #999; font-size: 12px;">Unable to calculate<br>(Try again later)</span>';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error calculating predicted rating:', error);
|
||||
predictedCell.textContent = 'Error';
|
||||
button.disabled = false;
|
||||
button.textContent = 'Predict Rating';
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlayerHistory(pdgaNumber) {
|
||||
const historyRow = document.getElementById(`history-${pdgaNumber}`);
|
||||
@@ -626,6 +600,252 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh functions
|
||||
async function refreshPlayer(pdgaNumber) {
|
||||
const icon = document.querySelector(`#row-${pdgaNumber} .rating .refresh-icon`);
|
||||
icon.classList.add('spinning');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/refresh-player/${pdgaNumber}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update the table with new data
|
||||
const row = document.getElementById(`row-${pdgaNumber}`);
|
||||
const ratingCell = row.querySelector('.rating');
|
||||
const ratingChangeCell = row.querySelector('.rating-change');
|
||||
|
||||
// Update player name
|
||||
const nameLink = row.querySelector('.player-name a');
|
||||
nameLink.textContent = data.player.name;
|
||||
|
||||
const ratingChangeText = data.player.ratingChange ?
|
||||
(data.player.ratingChange > 0 ? `+${data.player.ratingChange}` : data.player.ratingChange.toString()) : 'N/A';
|
||||
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'} <i class="fas fa-sync-alt refresh-icon" onclick="refreshPlayer(${pdgaNumber})" title="Refresh player data"></i>`;
|
||||
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) {
|
||||
mobileChange.textContent = ratingChangeText;
|
||||
mobileChange.className = `mobile-only rating-change ${ratingChangeClass}`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing player:', error);
|
||||
alert('Failed to refresh player data');
|
||||
} finally {
|
||||
icon.classList.remove('spinning');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRoundHistory(pdgaNumber) {
|
||||
const icon = document.querySelector(`#predicted-${pdgaNumber} .refresh-icon`);
|
||||
icon.classList.add('spinning');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle server error responses
|
||||
throw new Error(JSON.stringify(data));
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
||||
// Update difference calculation if the element exists
|
||||
const diffCell = document.getElementById(`diff-${pdgaNumber}`);
|
||||
if (diffCell) {
|
||||
const currentRatingElement = document.querySelector(`#row-${pdgaNumber} .rating .refresh-section`);
|
||||
if (currentRatingElement && currentRatingElement.firstChild) {
|
||||
const currentRatingText = currentRatingElement.firstChild.textContent;
|
||||
const currentRating = parseInt(currentRatingText);
|
||||
|
||||
if (data.predictedRating && currentRating && !isNaN(currentRating)) {
|
||||
const difference = data.predictedRating - currentRating;
|
||||
const diffText = difference > 0 ? `+${difference}` : difference.toString();
|
||||
const diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral';
|
||||
|
||||
diffCell.className = `difference ${diffClass}`;
|
||||
diffCell.textContent = diffText;
|
||||
} else {
|
||||
diffCell.innerHTML = '<span style="color: #999; font-style: italic;">Use refresh</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing round history:', error);
|
||||
|
||||
// Try to get detailed error information
|
||||
let errorMessage = 'Failed to refresh prediction data';
|
||||
let errorDetails = '';
|
||||
|
||||
try {
|
||||
// If error message is JSON, parse it
|
||||
const errorData = JSON.parse(error.message);
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
errorDetails = errorData.details || '';
|
||||
if (errorData.suggestion) {
|
||||
errorDetails += '\n\nSuggestion: ' + errorData.suggestion;
|
||||
}
|
||||
if (errorData.errorType) {
|
||||
errorDetails += '\n\nError Type: ' + errorData.errorType;
|
||||
}
|
||||
if (errorData.timestamp) {
|
||||
errorDetails += '\n\nTime: ' + new Date(errorData.timestamp).toLocaleString();
|
||||
}
|
||||
} catch (parseError) {
|
||||
// If not JSON, just use the error message
|
||||
errorDetails = error.message;
|
||||
}
|
||||
|
||||
const fullMessage = errorDetails ? errorMessage + '\n\nDetails:\n' + errorDetails : errorMessage;
|
||||
alert(fullMessage);
|
||||
} finally {
|
||||
icon.classList.remove('spinning');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRatingHistory(pdgaNumber) {
|
||||
const icon = document.querySelector(`#history-${pdgaNumber} .chart-title .refresh-icon`);
|
||||
icon.classList.add('spinning');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/refresh-rating-history/${pdgaNumber}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Refresh the chart
|
||||
const chartContainer = document.getElementById(`chart-${pdgaNumber}`);
|
||||
chartContainer.dataset.loaded = 'false'; // Mark as not loaded to force refresh
|
||||
|
||||
if (data.history && data.history.length > 0) {
|
||||
createRatingChart(chartContainer, data.history);
|
||||
chartContainer.dataset.loaded = 'true';
|
||||
} else {
|
||||
chartContainer.innerHTML = '<div class="loading-chart">No rating history available</div>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing rating history:', error);
|
||||
alert('Failed to refresh rating history');
|
||||
} finally {
|
||||
icon.classList.remove('spinning');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllPlayers() {
|
||||
const icons = document.querySelectorAll('th .refresh-icon');
|
||||
const ratingIcon = icons[0]; // First refresh icon in Rating header
|
||||
ratingIcon.classList.add('spinning');
|
||||
|
||||
try {
|
||||
const ratings = await getAllPlayersFromDB();
|
||||
displayRatings(ratings);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing all players:', error);
|
||||
alert('Failed to refresh player data');
|
||||
} finally {
|
||||
ratingIcon.classList.remove('spinning');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllPredictions() {
|
||||
const icons = document.querySelectorAll('th .refresh-icon');
|
||||
const predictedIcon = icons[1]; // Second refresh icon in Predicted header
|
||||
predictedIcon.classList.add('spinning');
|
||||
|
||||
try {
|
||||
// This would be a bulk operation - for now just show message
|
||||
alert('Bulk prediction refresh not implemented yet. Use individual refresh icons.');
|
||||
} catch (error) {
|
||||
console.error('Error refreshing all predictions:', error);
|
||||
alert('Failed to refresh predictions');
|
||||
} finally {
|
||||
predictedIcon.classList.remove('spinning');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllPlayers() {
|
||||
const button = document.getElementById('load-all-btn');
|
||||
const originalText = button.textContent;
|
||||
button.textContent = 'Loading...';
|
||||
button.style.pointerEvents = 'none';
|
||||
|
||||
try {
|
||||
const progressSection = document.getElementById('progress-section');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const tableDiv = document.getElementById('ratings-table');
|
||||
|
||||
progressSection.style.display = 'block';
|
||||
tableDiv.innerHTML = '';
|
||||
|
||||
const eventSource = new EventSource('/api/load-all-players');
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.status === 'loading' || data.status === 'completed' || data.status === 'error') {
|
||||
const percentage = Math.round((data.current / data.total) * 100);
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
progressBar.textContent = `${percentage}%`;
|
||||
|
||||
if (data.status === 'loading') {
|
||||
progressText.textContent = `Loading player ${data.current}/${data.total}: PDGA #${data.pdgaNumber}`;
|
||||
} else if (data.status === 'completed') {
|
||||
progressText.textContent = `Loaded ${data.name} (${data.current}/${data.total})`;
|
||||
} else if (data.status === 'error') {
|
||||
progressText.textContent = `Error loading PDGA #${data.pdgaNumber} (${data.current}/${data.total})`;
|
||||
}
|
||||
} else if (data.status === 'complete') {
|
||||
progressSection.style.display = 'none';
|
||||
displayRatings(data.ratings);
|
||||
eventSource.close();
|
||||
button.textContent = originalText;
|
||||
button.style.pointerEvents = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
progressSection.style.display = 'none';
|
||||
tableDiv.innerHTML = '<p>Connection error. Please refresh the page.</p>';
|
||||
eventSource.close();
|
||||
button.textContent = originalText;
|
||||
button.style.pointerEvents = 'auto';
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading all players:', error);
|
||||
button.textContent = originalText;
|
||||
button.style.pointerEvents = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
fetchRatingsWithProgress();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user