From 1e66b9f94fc2928e532e28b568c12a83ce9adb0b Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 13:15:16 +0200 Subject: [PATCH 1/3] feat: add target rating calculator (#2) --- public/css/players.css | 55 +++++++++++ public/js/players.js | 121 +++++++++++++++++++++++ src/routes/players.js | 67 +++++++++++++ src/services/target-rating-calculator.js | 72 ++++++++++++++ views/pages/index.ejs | 25 +++++ views/partials/ratings-table.ejs | 3 + 6 files changed, 343 insertions(+) create mode 100644 src/services/target-rating-calculator.js diff --git a/public/css/players.css b/public/css/players.css index 7e862e3..41dff4e 100644 --- a/public/css/players.css +++ b/public/css/players.css @@ -298,3 +298,58 @@ font-size: 13px; } } + +/* ── Target Rating Calculator ─────────────────── */ + +.target-rating-icon { + color: var(--accent); +} + +#target-rating-form .form-row { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +#target-rating-form .form-row label { + font-size: 0.9em; +} + +#target-rating-form .form-row input { + padding: 6px 8px; +} + +#target-rating-form .form-actions { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.target-rating-result { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.target-rating-result .target-summary div { + margin-bottom: 6px; +} + +.target-rating-result .warning { + color: var(--down); + margin-top: 8px; +} + +.target-rating-result .error { + color: var(--red); +} + +.target-rating-result .muted { + color: var(--text-muted); + font-size: 0.9em; +} + +.target-rating-result .loading { + color: var(--text-muted); +} diff --git a/public/js/players.js b/public/js/players.js index d310850..0867e8e 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -540,3 +540,124 @@ document.addEventListener('keydown', function(e) { 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) { + 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.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'; +} diff --git a/src/routes/players.js b/src/routes/players.js index 064b77e..963d804 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -8,6 +8,7 @@ const { launchBrowser } = require('../scrapers/browser'); const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB, formatDisplayDate } = require('../services/player-service'); const { getTopbarLocals } = require('../services/topbar-service'); const { calculatePredictedRating } = require('../services/rating-calculator'); +const { calculateRequiredAverage } = require('../services/target-rating-calculator'); const logger = require('../logger'); let refreshInProgress = false; @@ -442,4 +443,70 @@ router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => { } }); +router.post('/api/calculate-target-rating/:pdgaNumber', async (req, res) => { + const { pdgaNumber } = req.params; + const pdgaNum = parseInt(pdgaNumber, 10); + const { targetRating, rounds } = req.body || {}; + + if (!Number.isFinite(pdgaNum) || pdgaNum <= 0) { + return res.status(400).json({ error: 'Invalid PDGA number' }); + } + const target = Number(targetRating); + const numRounds = Number(rounds); + if (!Number.isFinite(target) || target < 400 || target > 1200) { + return res.status(400).json({ + error: 'Invalid target rating', + details: 'targetRating must be a number between 400 and 1200' + }); + } + if (!Number.isInteger(numRounds) || numRounds < 1 || numRounds > 20) { + return res.status(400).json({ + error: 'Invalid round count', + details: 'rounds must be an integer between 1 and 20' + }); + } + + try { + const dbRounds = await getRoundHistoryFromDB(pdgaNum); + if (!dbRounds || dbRounds.length === 0) { + return res.status(404).json({ + error: 'No round history available', + details: 'Refresh the player round history before calculating a target.', + errorType: 'NO_ROUNDS' + }); + } + + const roundRatings = dbRounds.map(r => ({ + rating: r.rating, + date: new Date(r.date), + competition: r.competition_name + })); + + const result = calculateRequiredAverage(roundRatings, target, numRounds); + + logger.info(`Target rating calc for PDGA ${pdgaNum}: target=${target} rounds=${numRounds} -> avg=${result.requiredAverage}`); + + return res.json({ + success: true, + pdgaNumber: pdgaNum, + targetRating: target, + rounds: numRounds, + currentRating: result.currentPredicted, + requiredAverage: result.requiredAverage, + predictedRating: result.simulatedPredicted, + warning: result.warning, + iterations: result.iterations + }); + } catch (err) { + logger.error(`Target rating calc failed for PDGA ${pdgaNum}: ${err.message}`); + return res.status(500).json({ + error: 'Failed to calculate target rating', + details: err.message, + errorType: err.code || 'CALC_ERROR', + timestamp: new Date().toISOString(), + suggestion: 'Try refreshing the round history and retrying.' + }); + } +}); + module.exports = router; diff --git a/src/services/target-rating-calculator.js b/src/services/target-rating-calculator.js new file mode 100644 index 0000000..197fccf --- /dev/null +++ b/src/services/target-rating-calculator.js @@ -0,0 +1,72 @@ +const { calculatePredictedRating, getNextPDGAUpdateDate } = require('./rating-calculator'); +const logger = require('../logger'); + +function calculateRequiredAverage(roundRatings, targetRating, numRounds) { + if (!Array.isArray(roundRatings) || roundRatings.length === 0) { + const err = new Error('No round history'); + err.code = 'NO_ROUNDS'; + throw err; + } + + const currentPredicted = calculatePredictedRating(roundRatings).rating; + const nextUpdate = getNextPDGAUpdateDate(); + const syntheticDate = new Date(nextUpdate.getTime() - 24 * 60 * 60 * 1000); + + const simulate = (R) => { + const synthetic = []; + for (let i = 0; i < numRounds; i++) { + synthetic.push({ rating: R, date: syntheticDate, competition: 'TARGET_SIM' }); + } + return calculatePredictedRating([...roundRatings, ...synthetic]).rating; + }; + + let lo = 400; + let hi = 1200; + let iterations = 0; + const maxIterations = 30; + let exactMatchAvg = null; + + while (iterations < maxIterations && (hi - lo) >= 0.5) { + const mid = (lo + hi) / 2; + const predicted = simulate(mid); + if (predicted === targetRating) { + exactMatchAvg = mid; + // Narrow toward smaller R that still hits target — but break to bound iterations. + hi = mid; + } else if (predicted < targetRating) { + lo = mid; + } else { + hi = mid; + } + iterations++; + } + + const candidate = exactMatchAvg !== null ? exactMatchAvg : (lo + hi) / 2; + const requiredAverage = Math.round(candidate * 10) / 10; + const simulatedPredicted = simulate(requiredAverage); + + let warning = null; + if (requiredAverage >= 1199.5) { + warning = 'Target may be unreachable with this number of rounds within the simulated range [400, 1200].'; + } else if (requiredAverage <= 400.5) { + warning = 'Required average is at the lower bound — target may already be exceeded or rounds would drag rating down.'; + } else if (requiredAverage > 1050) { + warning = 'Required average is extremely high (>1050) — practically very difficult.'; + } else if (requiredAverage < 600) { + warning = 'Required average is very low (<600) — check that your target is reasonable.'; + } else if (iterations >= maxIterations && Math.abs(simulatedPredicted - targetRating) > 1) { + warning = 'Could not converge precisely; result is approximate.'; + } + + logger.debug(`Target rating calc: target=${targetRating} rounds=${numRounds} → avg=${requiredAverage} (iterations=${iterations}, simulated=${simulatedPredicted})`); + + return { + requiredAverage, + currentPredicted, + simulatedPredicted, + iterations, + warning + }; +} + +module.exports = { calculateRequiredAverage }; diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 3b962b9..93d7791 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -102,6 +102,31 @@ + + + `; %> <%- include('../partials/layout', { diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index 4fc19f4..2d2493e 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -82,6 +82,9 @@ function renderSparkline(values) { + -- 2.52.0 From 96edc606d3e1cd720988df6552dd3e0b2cdef0a6 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 13:32:02 +0200 Subject: [PATCH 2/3] fix: offer refresh button when round history is empty When a player has rating_history (graph) but no round_history (per-round detail), calculating a target produced a dead-end error. Now the modal detects the NO_ROUNDS case and shows a button that triggers the existing refresh-round-history endpoint and re-runs the calculation on success. Handles the 24h rate-limit and other refresh errors explicitly. --- public/css/players.css | 7 +++++ public/js/players.js | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/public/css/players.css b/public/css/players.css index 41dff4e..458fd77 100644 --- a/public/css/players.css +++ b/public/css/players.css @@ -353,3 +353,10 @@ .target-rating-result .loading { color: var(--text-muted); } + +.target-rating-result .no-history-prompt { + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; +} diff --git a/public/js/players.js b/public/js/players.js index 0867e8e..9a62929 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -615,6 +615,10 @@ async function calculateTargetRating(event) { 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; @@ -661,3 +665,61 @@ async function calculateTargetRating(event) { function closeTargetRatingModal(event) { document.getElementById('target-rating-modal').style.display = 'none'; } + +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 || ''; + } + } + + 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.'); + } +} -- 2.52.0 From e29bc8ee805f079e4ccc07104a709b48b17a9c64 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 13:43:25 +0200 Subject: [PATCH 3/3] feat: show sensitivity bracket around required average MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the binary search converges, also simulate predicted rating at required±1 average. Display the three rows in the modal so the user can see how sharp the requirement is — e.g. whether averaging 1 point lower costs them 1 point of predicted rating or 5. --- public/css/players.css | 21 ++++++++++++++++++ public/js/players.js | 28 ++++++++++++++++++++++++ src/routes/players.js | 3 ++- src/services/target-rating-calculator.js | 11 +++++++++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/public/css/players.css b/public/css/players.css index 458fd77..635d7b3 100644 --- a/public/css/players.css +++ b/public/css/players.css @@ -360,3 +360,24 @@ gap: 12px; align-items: flex-start; } + +.target-rating-result .sensitivity { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border); +} + +.target-rating-result .sensitivity-heading { + font-size: 0.9em; + color: var(--text-muted); + margin-bottom: 4px; +} + +.target-rating-result .sensitivity-row { + font-variant-numeric: tabular-nums; + padding-left: 12px; +} + +.target-rating-result .sensitivity-row.is-target { + font-weight: 600; +} diff --git a/public/js/players.js b/public/js/players.js index 9a62929..ee5797a 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -647,6 +647,10 @@ async function calculateTargetRating(event) { 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); } @@ -666,6 +670,30 @@ 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'; diff --git a/src/routes/players.js b/src/routes/players.js index 963d804..783c5f3 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -495,7 +495,8 @@ router.post('/api/calculate-target-rating/:pdgaNumber', async (req, res) => { requiredAverage: result.requiredAverage, predictedRating: result.simulatedPredicted, warning: result.warning, - iterations: result.iterations + iterations: result.iterations, + sensitivity: result.sensitivity }); } catch (err) { logger.error(`Target rating calc failed for PDGA ${pdgaNum}: ${err.message}`); diff --git a/src/services/target-rating-calculator.js b/src/services/target-rating-calculator.js index 197fccf..a8e5054 100644 --- a/src/services/target-rating-calculator.js +++ b/src/services/target-rating-calculator.js @@ -58,6 +58,14 @@ function calculateRequiredAverage(roundRatings, targetRating, numRounds) { warning = 'Could not converge precisely; result is approximate.'; } + const lowerAvg = Math.max(400, Math.round((requiredAverage - 1) * 10) / 10); + const upperAvg = Math.min(1200, Math.round((requiredAverage + 1) * 10) / 10); + const sensitivity = { + lower: { average: lowerAvg, predicted: simulate(lowerAvg) }, + target: { average: requiredAverage, predicted: simulatedPredicted }, + upper: { average: upperAvg, predicted: simulate(upperAvg) } + }; + logger.debug(`Target rating calc: target=${targetRating} rounds=${numRounds} → avg=${requiredAverage} (iterations=${iterations}, simulated=${simulatedPredicted})`); return { @@ -65,7 +73,8 @@ function calculateRequiredAverage(roundRatings, targetRating, numRounds) { currentPredicted, simulatedPredicted, iterations, - warning + warning, + sensitivity }; } -- 2.52.0