Files
pdga-rating/public/js/players.js
T

544 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 setupTooltipsAfterSwap() {
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail.target;
if (target.id === 'ratings-table') {
initRatingsTooltips();
initChartsIn(target); // initial table render — chart any pre-loaded .player-chart
return;
}
// refreshRatingHistory still re-fetches into #history-content-<id>
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));
});