const cachedDebugInfo = {}; let pendingPlayerData = null; let openPdgaNumber = null; // ── Delta-pill helper ───────────────────────────── function renderDeltaPill(value, extraClass) { const isNull = (value == null); const cls = isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; const glyph = (isNull || value === 0) ? '–' : value > 0 ? '▲' : '▼'; const num = isNull ? '—' : value > 0 ? '+' + value : String(value); return { glyph, num, cls }; } function applyDeltaPill(pillEl, value) { if (!pillEl) return; const pill = renderDeltaPill(value); pillEl.className = 'delta-pill ' + pill.cls; while (pillEl.firstChild) pillEl.removeChild(pillEl.firstChild); const glyphSpan = document.createElement('span'); glyphSpan.className = 'delta-glyph'; glyphSpan.textContent = pill.glyph; const numSpan = document.createElement('span'); numSpan.className = 'delta-num'; numSpan.textContent = pill.num; pillEl.appendChild(glyphSpan); pillEl.appendChild(numSpan); } function initChartsIn(rootEl) { rootEl.querySelectorAll('.player-chart').forEach(function(container) { if (container.dataset.charted === 'true') return; if (!container.dataset.history) return; try { const history = JSON.parse(container.dataset.history); createRatingChart(container, history); container.dataset.charted = 'true'; } catch (e) { console.error('Error rendering chart:', e); } }); } function setupAfterTableSwap() { document.body.addEventListener('htmx:afterSwap', function(event) { const target = event.detail.target; if (target.id === 'ratings-table') { initRatingsTooltips(); initChartsIn(target); return; } if (target.id && target.id.startsWith('history-content-')) { initChartsIn(target); } }); } function initRatingsTooltips() { 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) { setupTooltip(span, tooltip, () => `Standard Deviation: \u00b1${stdDev}`); } }); 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; setupTooltip(span, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`); } }); } function togglePlayerHistory(pdgaNumber) { const historyRow = document.getElementById('history-' + pdgaNumber); const contentDiv = document.getElementById('history-content-' + pdgaNumber); const expandableRow = document.getElementById('row-' + pdgaNumber); const isOpen = historyRow.style.display === 'table-row'; // Close any previously-open row if (openPdgaNumber !== null && openPdgaNumber !== pdgaNumber) { const prevHistory = document.getElementById('history-' + openPdgaNumber); const prevRow = document.getElementById('row-' + openPdgaNumber); if (prevHistory) { prevHistory.style.display = 'none'; prevHistory.classList.remove('is-open'); } if (prevRow) prevRow.classList.remove('row-open'); openPdgaNumber = null; } if (isOpen) { historyRow.style.display = 'none'; historyRow.classList.remove('is-open'); expandableRow.classList.remove('row-open'); openPdgaNumber = null; return; } historyRow.style.display = 'table-row'; // Force reflow so animation plays each open historyRow.classList.remove('is-open'); void historyRow.offsetWidth; historyRow.classList.add('is-open'); expandableRow.classList.add('row-open'); openPdgaNumber = pdgaNumber; if (contentDiv.dataset.loaded === 'true') { return; } htmx.ajax('GET', '/partials/player-history/' + pdgaNumber, {target: '#history-content-' + pdgaNumber, swap: 'innerHTML'}); contentDiv.dataset.loaded = 'true'; } async function clearCache() { try { const response = await fetch('/api/clear-cache', { method: 'POST' }); const data = await response.json(); if (data.success) { alert(data.message); if (confirm('Reload page to fetch fresh data?')) { location.reload(); } } else { alert('Failed to clear cache'); } } catch (error) { console.error('Error clearing cache:', error); alert('Error clearing cache'); } } // Refreshes both the current rating and the prediction in one click, then // re-swaps the table so every derived value (deltas, pills, sparkline) reflects // the new state. Cheaper than fine-grained DOM updates and guaranteed consistent // because the server renders the truth. async function refreshPlayerData(pdgaNumber) { const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`); if (icon) icon.classList.add('spinning'); try { await Promise.allSettled([ fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }), fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' }) ]); htmx.ajax('GET', '/partials/ratings-table', { target: '#ratings-table', swap: 'innerHTML' }); } catch (error) { console.error('Error refreshing player data:', error); } finally { if (icon) icon.classList.remove('spinning'); } } async function refreshPlayer(pdgaNumber) { try { const response = await fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }); const data = await response.json(); if (data.success) { const row = document.getElementById(`row-${pdgaNumber}`); const ratingCell = row.querySelector('.cell-rating'); const nameLink = row.querySelector('.player-name a'); if (nameLink) nameLink.textContent = data.player.name; const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null; if (ratingValue) { ratingValue.textContent = data.player.rating || 'N/A'; ratingValue.dataset.rating = data.player.rating || ''; const stdDev = parseInt(ratingValue.dataset.stddev); const rating = parseInt(data.player.rating); const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); if (rating && stdDev && tooltip) { const minRating = rating - stdDev; const maxRating = rating + stdDev; replaceWithTooltip(ratingValue, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`); } } const deltaMonthPill = ratingCell ? ratingCell.querySelector('.delta-pill') : null; applyDeltaPill(deltaMonthPill, data.player.ratingChange); } } catch (error) { console.error('Error refreshing player:', error); alert('Failed to refresh player data'); } } async function refreshRoundHistory(pdgaNumber) { try { const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' }); const data = await response.json(); if (!response.ok) { throw new Error(JSON.stringify(data)); } if (data.success) { if (data.debugLog) { cachedDebugInfo[pdgaNumber] = data.debugLog; } const predictedCell = document.getElementById(`predicted-${pdgaNumber}`); if (predictedCell) { const predictedValue = predictedCell.querySelector('.predicted-value'); if (predictedValue) { predictedValue.textContent = data.predictedRating || 'N/A'; predictedValue.dataset.stddev = data.stdDev || ''; const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`); if (data.stdDev && tooltip) { replaceWithTooltip(predictedValue, tooltip, () => `Standard Deviation: \u00b1${data.stdDev}`); } } } const row = document.getElementById(`row-${pdgaNumber}`); const ratingCell = row.querySelector('.cell-rating'); const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null; if (ratingValue && data.stdDev) { ratingValue.dataset.stddev = data.stdDev; const rating = parseInt(ratingValue.dataset.rating); const stdDev = parseInt(data.stdDev); const ratingTooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); if (rating && stdDev && ratingTooltip) { const minRating = rating - stdDev; const maxRating = rating + stdDev; replaceWithTooltip(ratingValue, ratingTooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`); } } } } catch (error) { // Rate-limited or scrape failure — common when refresh runs alongside the // current-rating refresh. Log but don't alert; the user-facing surface is // the spinner stopping (data may or may not have updated). console.error('Error refreshing round history:', error); } } async function refreshRatingHistory(pdgaNumber) { // No dedicated icon in the expanded row; spinner state not needed here const icon = null; try { const response = await fetch(`/api/refresh-rating-history/${pdgaNumber}`, { method: 'POST' }); const data = await response.json(); if (data.success) { const contentDiv = document.getElementById(`history-content-${pdgaNumber}`); contentDiv.dataset.loaded = 'false'; htmx.ajax('GET', `/partials/player-history/${pdgaNumber}`, {target: `#history-content-${pdgaNumber}`, swap: 'innerHTML'}); contentDiv.dataset.loaded = 'true'; } } catch (error) { console.error('Error refreshing rating history:', error); alert('Failed to refresh rating history'); } } async function showDebugInfo(pdgaNumber) { const modal = document.getElementById('debug-modal'); const header = document.getElementById('debug-header'); const log = document.getElementById('debug-log'); const playerNameElement = document.querySelector(`#row-${pdgaNumber} .player-name a`); const playerName = playerNameElement ? playerNameElement.textContent : `PDGA #${pdgaNumber}`; header.textContent = `Prediction Calculation Details - ${playerName}`; log.textContent = 'Loading calculation details...'; modal.style.display = 'flex'; try { if (cachedDebugInfo[pdgaNumber]) { log.textContent = cachedDebugInfo[pdgaNumber].join('\n'); return; } const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' }); const data = await response.json(); if (data.success && data.debugLog) { cachedDebugInfo[pdgaNumber] = data.debugLog; log.textContent = data.debugLog.join('\n'); } else { log.textContent = 'No debug information available. Try refreshing the prediction first.'; } } catch (error) { console.error('Error fetching debug info:', error); log.textContent = 'Error loading debug information. Please try again.'; } } function closeDebugModal(event) { document.getElementById('debug-modal').style.display = 'none'; } async function searchAndAddPlayer(event) { if (event) event.preventDefault(); const input = document.getElementById('pdga-number-input'); const pdgaNumber = input.value.trim(); if (!pdgaNumber) { alert('Please enter a PDGA number'); return; } const button = document.querySelector('.add-bar button[type="submit"]'); const originalText = button ? button.textContent : ''; if (button) { button.disabled = true; button.textContent = 'Searching...'; } try { const response = await fetch(`/api/search-player/${pdgaNumber}`); const data = await response.json(); if (!response.ok) { showErrorModal(data.error || 'Player not found'); return; } if (data.alreadyExists) { showInfoModal(`${data.player.name} is already being tracked!`); return; } pendingPlayerData = data.player; showConfirmationModal(data.player); } catch (error) { console.error('Error searching for player:', error); showErrorModal('Failed to search for player. Please try again.'); } finally { if (button) { button.disabled = false; button.textContent = originalText; } } } function showConfirmationModal(player) { const modal = document.getElementById('add-player-modal'); document.getElementById('add-player-modal-header').textContent = 'Confirm Player'; const body = document.getElementById('add-player-modal-body'); body.textContent = ''; const question = document.createElement('p'); question.textContent = 'Is this the correct player you want to add?'; body.appendChild(question); const info = document.createElement('div'); info.style.cssText = 'background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin-top: 15px;'; const name = document.createElement('strong'); name.style.cssText = 'font-size: 18px; color: #007bff;'; name.textContent = player.name; info.appendChild(name); info.appendChild(document.createElement('br')); const pdga = document.createElement('span'); pdga.style.color = '#6c757d'; pdga.textContent = `PDGA #${player.pdgaNumber}`; info.appendChild(pdga); info.appendChild(document.createElement('br')); const rating = document.createElement('span'); if (player.rating) { rating.style.cssText = 'color: #28a745; font-weight: bold;'; rating.textContent = `Current Rating: ${player.rating}`; } else { rating.style.color = '#999'; rating.textContent = 'No rating available'; } info.appendChild(rating); body.appendChild(info); const footer = document.getElementById('add-player-modal-footer'); footer.textContent = ''; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = closeAddPlayerModal; footer.appendChild(cancelBtn); const confirmBtn = document.createElement('button'); confirmBtn.className = 'btn btn-confirm'; confirmBtn.textContent = 'Add Player'; confirmBtn.onclick = confirmAddPlayer; footer.appendChild(confirmBtn); modal.style.display = 'flex'; } function showErrorModal(message) { const modal = document.getElementById('add-player-modal'); document.getElementById('add-player-modal-header').textContent = 'Player Not Found'; const body = document.getElementById('add-player-modal-body'); body.textContent = ''; const errorP = document.createElement('p'); errorP.style.color = '#dc3545'; errorP.textContent = message; body.appendChild(errorP); const helpP = document.createElement('p'); helpP.style.cssText = 'margin-top: 10px; color: #6c757d; font-size: 14px;'; helpP.textContent = 'Please check the PDGA number and try again.'; body.appendChild(helpP); const footer = document.getElementById('add-player-modal-footer'); footer.textContent = ''; const closeBtn = document.createElement('button'); closeBtn.className = 'btn btn-cancel'; closeBtn.textContent = 'Close'; closeBtn.onclick = closeAddPlayerModal; footer.appendChild(closeBtn); modal.style.display = 'flex'; } function showInfoModal(message) { const modal = document.getElementById('add-player-modal'); document.getElementById('add-player-modal-header').textContent = 'Information'; const body = document.getElementById('add-player-modal-body'); body.textContent = ''; const infoP = document.createElement('p'); infoP.style.color = '#007bff'; infoP.textContent = message; body.appendChild(infoP); const footer = document.getElementById('add-player-modal-footer'); footer.textContent = ''; const closeBtn = document.createElement('button'); closeBtn.className = 'btn btn-cancel'; closeBtn.textContent = 'Close'; closeBtn.onclick = closeAddPlayerModal; footer.appendChild(closeBtn); modal.style.display = 'flex'; } async function confirmAddPlayer() { if (!pendingPlayerData) { closeAddPlayerModal(); return; } const body = document.getElementById('add-player-modal-body'); body.textContent = 'Adding player...'; document.getElementById('add-player-modal-footer').textContent = ''; try { const response = await fetch('/api/add-player', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pdgaNumber: pendingPlayerData.pdgaNumber }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to add player'); } body.textContent = ''; const successP = document.createElement('p'); successP.style.cssText = 'color: #28a745; text-align: center;'; successP.textContent = `${data.player.name} has been added successfully!`; body.appendChild(successP); const footer = document.getElementById('add-player-modal-footer'); footer.textContent = ''; const okBtn = document.createElement('button'); okBtn.className = 'btn btn-confirm'; okBtn.textContent = 'OK'; okBtn.onclick = function() { closeAddPlayerModal(); location.reload(); }; footer.appendChild(okBtn); document.getElementById('pdga-number-input').value = ''; pendingPlayerData = null; } catch (error) { console.error('Error adding player:', error); body.textContent = error.message; body.style.color = '#dc3545'; const footer = document.getElementById('add-player-modal-footer'); footer.textContent = ''; const closeBtn = document.createElement('button'); closeBtn.className = 'btn btn-cancel'; closeBtn.textContent = 'Close'; closeBtn.onclick = closeAddPlayerModal; footer.appendChild(closeBtn); } } function closeAddPlayerModal(event) { document.getElementById('add-player-modal').style.display = 'none'; pendingPlayerData = null; } // ── Sparkline toggle ─────────────────────────────── document.addEventListener('DOMContentLoaded', function() { const btn = document.getElementById('trendchart-toggle'); if (!btn) return; const state = localStorage.getItem('ratingtracker.sparklines') || 'on'; document.body.dataset.sparklines = state; btn.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false'); btn.addEventListener('click', function() { const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on'; document.body.dataset.sparklines = next; btn.setAttribute('aria-pressed', next === 'on' ? 'true' : 'false'); localStorage.setItem('ratingtracker.sparklines', next); }); }); // ── Expandable row keyboard support ─────────────── document.addEventListener('keydown', function(e) { if (e.key !== 'Enter' && e.key !== ' ') return; const row = e.target; if (!row.classList || !row.classList.contains('expandable-row')) return; e.preventDefault(); const pdgaNumber = row.id.replace('row-', ''); togglePlayerHistory(parseInt(pdgaNumber, 10)); });