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 }; }