1ff768e2fa
- A: create inline span when missing in refreshRoundHistory (was silently dropped) - B: updateStdDevInline also called from refreshHistoryThenCalculate - C: extract stdDevTooltipText + updateStdDevInline helpers; replace 3 call sites - D: remove margin-left: 4px and bump font-size to 12px on .std-dev-inline - E: guard against stdDev === 0 in EJS (truthy → != null)
863 lines
32 KiB
JavaScript
863 lines
32 KiB
JavaScript
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);
|
||
}
|
||
|
||
// ── Std-dev helpers ───────────────────────────────
|
||
function stdDevTooltipText(rating, stdDev) {
|
||
return `Spridning: ±${stdDev} poäng\nIntervall: ${rating - stdDev}–${rating + stdDev}`;
|
||
}
|
||
|
||
function updateStdDevInline(predStack, stdDev) {
|
||
if (!predStack) return;
|
||
let stdDevInline = predStack.querySelector('.std-dev-inline');
|
||
if (stdDev) {
|
||
if (!stdDevInline) {
|
||
stdDevInline = document.createElement('span');
|
||
stdDevInline.className = 'std-dev-inline mono';
|
||
predStack.appendChild(stdDevInline);
|
||
}
|
||
stdDevInline.textContent = '±' + stdDev;
|
||
stdDevInline.style.display = '';
|
||
} else if (stdDevInline) {
|
||
stdDevInline.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
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') {
|
||
initRatingsTooltips();
|
||
initChartsIn(target);
|
||
return;
|
||
}
|
||
if (target.id && target.id.startsWith('history-content-')) {
|
||
initChartsIn(target);
|
||
}
|
||
});
|
||
}
|
||
|
||
function initRatingsTooltips() {
|
||
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) {
|
||
setupTooltip(span, tooltip, () => stdDevTooltipText(rating, 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) {
|
||
replaceWithTooltip(ratingValue, tooltip, () => stdDevTooltipText(rating, 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 || '';
|
||
|
||
updateStdDevInline(predictedValue.closest('.pred-stack'), 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) {
|
||
replaceWithTooltip(ratingValue, ratingTooltip, () => stdDevTooltipText(rating, 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 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;
|
||
}
|
||
|
||
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 || '';
|
||
updateStdDevInline(predictedValue.closest('.pred-stack'), 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 = '';
|
||
}
|
||
}
|