feat: show sensitivity bracket around required average
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.
This commit is contained in:
@@ -360,3 +360,24 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -647,6 +647,10 @@ async function calculateTargetRating(event) {
|
|||||||
mutedLine.textContent = 'Across ' + data.rounds + ' synthetic rounds before the next PDGA update.';
|
mutedLine.textContent = 'Across ' + data.rounds + ' synthetic rounds before the next PDGA update.';
|
||||||
summary.appendChild(mutedLine);
|
summary.appendChild(mutedLine);
|
||||||
|
|
||||||
|
if (data.sensitivity) {
|
||||||
|
renderSensitivity(data.sensitivity, summary);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.warning) {
|
if (data.warning) {
|
||||||
_targetResultMsg(summary, 'warning', data.warning);
|
_targetResultMsg(summary, 'warning', data.warning);
|
||||||
}
|
}
|
||||||
@@ -666,6 +670,30 @@ function closeTargetRatingModal(event) {
|
|||||||
document.getElementById('target-rating-modal').style.display = 'none';
|
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) {
|
function renderNoHistoryPrompt(pdgaNumber, container) {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'no-history-prompt';
|
wrapper.className = 'no-history-prompt';
|
||||||
|
|||||||
@@ -495,7 +495,8 @@ router.post('/api/calculate-target-rating/:pdgaNumber', async (req, res) => {
|
|||||||
requiredAverage: result.requiredAverage,
|
requiredAverage: result.requiredAverage,
|
||||||
predictedRating: result.simulatedPredicted,
|
predictedRating: result.simulatedPredicted,
|
||||||
warning: result.warning,
|
warning: result.warning,
|
||||||
iterations: result.iterations
|
iterations: result.iterations,
|
||||||
|
sensitivity: result.sensitivity
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Target rating calc failed for PDGA ${pdgaNum}: ${err.message}`);
|
logger.error(`Target rating calc failed for PDGA ${pdgaNum}: ${err.message}`);
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ function calculateRequiredAverage(roundRatings, targetRating, numRounds) {
|
|||||||
warning = 'Could not converge precisely; result is approximate.';
|
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})`);
|
logger.debug(`Target rating calc: target=${targetRating} rounds=${numRounds} → avg=${requiredAverage} (iterations=${iterations}, simulated=${simulatedPredicted})`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -65,7 +73,8 @@ function calculateRequiredAverage(roundRatings, targetRating, numRounds) {
|
|||||||
currentPredicted,
|
currentPredicted,
|
||||||
simulatedPredicted,
|
simulatedPredicted,
|
||||||
iterations,
|
iterations,
|
||||||
warning
|
warning,
|
||||||
|
sensitivity
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user