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); const isMobile = container.dataset.variant === 'mobile'; if (isMobile) { createRatingChart(container, history, { w: 360, h: 160, padding: { left: 36, right: 12, top: 14, bottom: 24 }, tickCount: 3, xLabelCount: 3, dotR: 2, lastDotR: 3 }); } else { 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') { initChartsIn(target); return; } if (target.id && target.id.startsWith('history-content-')) { initChartsIn(target); } }); } 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. The mobile cards partial is included // inside ratings-table, so swapping #ratings-table re-renders both views at once. async function refreshPlayerData(pdgaNumber) { // The desktop row exists in the DOM even on mobile (hidden via CSS), so spin // both possible icons; only the one visible in the active viewport is seen. const icons = [ document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`), document.querySelector(`#m-card-${pdgaNumber} .m-refresh-icon`) ].filter(Boolean); icons.forEach(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 { icons.forEach(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 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) { 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 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; } } } 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 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 SPARKLINE_KEY = 'ratingtracker.sparklines'; function syncSparklineButtons(state) { const btns = document.querySelectorAll('#trendchart-toggle, #trendchart-toggle-mobile'); btns.forEach(function(b) { b.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false'); }); } const state = localStorage.getItem(SPARKLINE_KEY) || 'on'; document.body.dataset.sparklines = state; syncSparklineButtons(state); document.body.addEventListener('click', function(e) { const target = e.target.closest('#trendchart-toggle, #trendchart-toggle-mobile'); if (!target) return; const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on'; document.body.dataset.sparklines = next; localStorage.setItem(SPARKLINE_KEY, next); syncSparklineButtons(next); }); // Re-sync after HTMX table swap (mobile button is inside the swapped partial) document.body.addEventListener('htmx:afterSwap', function(event) { const target = event.detail.target; if (target.id === 'ratings-table') { syncSparklineButtons(document.body.dataset.sparklines || 'on'); } }); }); // ── 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)); }); // ── Target Rating Calculator ─────────────────────── function openTargetRatingModal(pdgaNumber) { const modal = document.getElementById('target-rating-modal'); const header = document.getElementById('target-rating-modal-header'); const pdgaField = document.getElementById('target-rating-pdga'); const result = document.getElementById('target-rating-result'); const targetInput = document.getElementById('target-rating-input'); const roundsInput = document.getElementById('target-rounds-input'); const submitBtn = document.getElementById('target-rating-submit'); const playerNameEl = document.querySelector('#row-' + pdgaNumber + ' .player-name a'); const playerName = playerNameEl ? playerNameEl.textContent : 'PDGA #' + pdgaNumber; header.textContent = 'Calculate Target Rating — ' + playerName; pdgaField.value = pdgaNumber; result.style.display = 'none'; while (result.firstChild) result.removeChild(result.firstChild); targetInput.value = ''; roundsInput.value = '4'; submitBtn.disabled = false; submitBtn.textContent = 'Calculate'; modal.style.display = 'flex'; targetInput.focus(); } function _targetResultMsg(parent, cls, text) { const d = document.createElement('div'); d.className = cls; d.textContent = text; parent.appendChild(d); } async function calculateTargetRating(event) { if (event) event.preventDefault(); const pdgaNumber = document.getElementById('target-rating-pdga').value; const targetRating = parseInt(document.getElementById('target-rating-input').value, 10); const rounds = parseInt(document.getElementById('target-rounds-input').value, 10); const result = document.getElementById('target-rating-result'); const submitBtn = document.getElementById('target-rating-submit'); function clearResult() { while (result.firstChild) result.removeChild(result.firstChild); } if (!Number.isInteger(targetRating) || targetRating < 400 || targetRating > 1200) { result.style.display = 'block'; clearResult(); _targetResultMsg(result, 'error', 'Target rating must be 400-1200.'); return; } if (!Number.isInteger(rounds) || rounds < 1 || rounds > 20) { result.style.display = 'block'; clearResult(); _targetResultMsg(result, 'error', 'Rounds must be an integer 1-20.'); return; } submitBtn.disabled = true; submitBtn.textContent = 'Calculating...'; result.style.display = 'block'; clearResult(); _targetResultMsg(result, 'loading', 'Calculating...'); try { const response = await fetch('/api/calculate-target-rating/' + pdgaNumber, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetRating: targetRating, rounds: rounds }) }); const data = await response.json(); clearResult(); if (!response.ok || !data.success) { if (response.status === 404 && data.errorType === 'NO_ROUNDS') { renderNoHistoryPrompt(pdgaNumber, result); return; } const msg = data.details ? data.error + ': ' + data.details : (data.error || 'Calculation failed'); _targetResultMsg(result, 'error', msg); return; } const summary = document.createElement('div'); summary.className = 'target-summary'; const avgLine = document.createElement('div'); const avgStrong = document.createElement('strong'); avgStrong.textContent = 'Required round average: '; avgLine.appendChild(avgStrong); avgLine.appendChild(document.createTextNode(String(data.requiredAverage))); summary.appendChild(avgLine); const currentLine = document.createElement('div'); currentLine.textContent = 'Current predicted rating: ' + data.currentRating; summary.appendChild(currentLine); const simLine = document.createElement('div'); simLine.textContent = 'Simulated predicted rating at this average: ' + data.predictedRating; summary.appendChild(simLine); const mutedLine = document.createElement('div'); mutedLine.className = 'muted'; mutedLine.textContent = 'Across ' + data.rounds + ' synthetic rounds before the next PDGA update.'; summary.appendChild(mutedLine); if (data.sensitivity) { renderSensitivity(data.sensitivity, summary); } if (data.warning) { _targetResultMsg(summary, 'warning', data.warning); } result.appendChild(summary); } catch (err) { console.error('Error calculating target rating:', err); clearResult(); _targetResultMsg(result, 'error', 'Network error. Please try again.'); } finally { submitBtn.disabled = false; submitBtn.textContent = 'Calculate'; } } function closeTargetRatingModal(event) { document.getElementById('target-rating-modal').style.display = 'none'; } function renderSensitivity(sensitivity, container) { const wrapper = document.createElement('div'); wrapper.className = 'sensitivity'; const heading = document.createElement('div'); heading.className = 'sensitivity-heading'; heading.textContent = 'Sensitivity:'; wrapper.appendChild(heading); const rows = [ { row: sensitivity.lower, isTarget: false }, { row: sensitivity.target, isTarget: true }, { row: sensitivity.upper, isTarget: false } ]; for (const { row, isTarget } of rows) { const line = document.createElement('div'); line.className = 'sensitivity-row' + (isTarget ? ' is-target' : ''); line.textContent = 'Average ' + row.average + ' → predicted ' + row.predicted + (isTarget ? ' (target)' : ''); wrapper.appendChild(line); } container.appendChild(wrapper); } function renderNoHistoryPrompt(pdgaNumber, container) { const wrapper = document.createElement('div'); wrapper.className = 'no-history-prompt'; const msg = document.createElement('div'); msg.textContent = 'No round-level history is stored for this player yet. Refresh from PDGA to enable the calculation.'; wrapper.appendChild(msg); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-confirm'; btn.textContent = 'Refresh round history & calculate'; btn.addEventListener('click', function () { refreshHistoryThenCalculate(pdgaNumber); }); wrapper.appendChild(btn); container.appendChild(wrapper); } async function refreshHistoryThenCalculate(pdgaNumber) { const result = document.getElementById('target-rating-result'); while (result.firstChild) result.removeChild(result.firstChild); _targetResultMsg(result, 'loading', 'Refreshing round history from PDGA — this may take up to 30 seconds...'); try { const response = await fetch('/api/refresh-round-history/' + pdgaNumber, { method: 'POST' }); const data = await response.json(); while (result.firstChild) result.removeChild(result.firstChild); if (response.status === 429) { const hours = data.hoursRemaining ? data.hoursRemaining + ' hour(s)' : 'a while'; _targetResultMsg(result, 'error', 'Round history was refreshed recently. Try again in ' + hours + '.'); return; } if (!response.ok || !data.success) { const msg = data.details ? data.error + ': ' + data.details : (data.error || 'Refresh failed'); _targetResultMsg(result, 'error', msg); return; } 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 || ''; } } await calculateTargetRating(null); } catch (err) { console.error('Error refreshing round history:', err); while (result.firstChild) result.removeChild(result.firstChild); _targetResultMsg(result, 'error', 'Network error during refresh. Please try again.'); } } // ── Mobile player card toggle ────────────────────── let openMobilePdgaNumber = null; function toggleMobilePlayerCard(pdgaNumber) { const card = document.getElementById('m-card-' + pdgaNumber); if (!card) return; const isOpen = card.classList.contains('is-open'); // Close previously open card if (openMobilePdgaNumber !== null && openMobilePdgaNumber !== pdgaNumber) { const prevCard = document.getElementById('m-card-' + openMobilePdgaNumber); if (prevCard) { prevCard.classList.remove('is-open'); prevCard.setAttribute('aria-expanded', 'false'); } openMobilePdgaNumber = null; } if (isOpen) { card.classList.remove('is-open'); card.setAttribute('aria-expanded', 'false'); openMobilePdgaNumber = null; return; } card.classList.add('is-open'); card.setAttribute('aria-expanded', 'true'); openMobilePdgaNumber = pdgaNumber; // Init charts inside the expand panel const expand = card.querySelector('.m-card__expand'); if (expand) { initChartsIn(expand); } } // ── Mobile add player ────────────────────────────── async function searchAndAddPlayerMobile(event) { if (event) event.preventDefault(); const input = document.getElementById('pdga-number-input-mobile'); const pdgaNumber = input ? input.value.trim() : ''; if (!pdgaNumber) { alert('Please enter a PDGA number'); return; } const button = event && event.target ? event.target.querySelector('button[type="submit"]') : null; 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 = 'Add'; } if (input) input.value = ''; } }