From 1e66b9f94fc2928e532e28b568c12a83ce9adb0b Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 13:15:16 +0200 Subject: [PATCH] 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) { +