Compare commits

...

10 Commits

Author SHA1 Message Date
Samuel Enocsson 31d80273b8 chore: address review feedback on predicted rating logging (#11)
- align diagnostic endpoint date windows with getNextPDGAUpdateDate
- pass pdgaNumber to initial target-rating calculation for log attribution
- avoid double log when lazy-recompute returns 0
- log full error object in admin endpoint catch
2026-05-25 22:02:42 +02:00
Samuel Enocsson 83ba20d428 chore: add diagnostic logging for predicted rating lifecycle (#11)
Add structured Pino logging across four hot-spots to make the
predicted rating data-loss visible in production logs:

- rating-calculator.js: log on calculation start, each 0-return early
  exit (no_rounds / no_rounds_after_date_filter / no_eligible_rounds),
  and on successful calculation with full algorithm branch metadata.
  Accepts optional context={pdgaNumber,callsite} to correlate logs.

- models/player.js: savePlayerToDB now logs before INSERT OR REPLACE,
  explicitly naming the derived columns that will be reset to NULL.
  savePredictedRatingToDB reads the old value first and emits a warn
  when a positive predicted_rating is overwritten with 0.

- services/player-service.js: structured debug/info/warn logs in
  getPlayerDataFromDB (lazy recompute trigger, failed recompute) and
  getPredictedRatingFromDB (no round history, 0 despite having rounds).

- routes/players.js: refresh-round-history route now logs each step
  (official_history_scrape, rounds_scrape_save, predicted_calc) with
  success/count fields; outer catch uses structured error log.
  refresh-all and refresh-player log their INSERT OR REPLACE trigger.

Also adds GET /api/admin/player-state/:pdgaNumber — a read-only
diagnostic endpoint returning raw DB columns plus round count and
date-window breakdowns.

Closes #11
2026-05-25 21:55:53 +02:00
Release Bot a90f2d0e86 1.4.0
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 21s
Build and deploy / deploy (push) Successful in 7s
2026-05-25 09:18:47 +00:00
shcizo f8233960d2 Merge pull request 'feat: show excluded rounds count and cutoff rating in player history (#21)' (#23) from feat/show-excluded-rounds-count-21 into main
Release / release (push) Successful in 7s
2026-05-25 11:18:35 +02:00
Samuel Enocsson 98a6c6be2e feat: show cutoff rating threshold in player history accordion (#21) 2026-05-25 11:12:01 +02:00
Samuel Enocsson 9138299ae0 Merge remote-tracking branch 'origin/main' into feat/show-excluded-rounds-count-21 2026-05-25 11:01:53 +02:00
Release Bot 3275241aa7 1.3.0
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 25s
Build and deploy / deploy (push) Successful in 8s
2026-05-25 08:52:17 +00:00
shcizo 6faddc6232 Merge pull request 'feat: Courses-redesign + Tjing-import (#8)' (#22) from feat/courses-redesign-tjing-import-8 into main
Release / release (push) Successful in 7s
2026-05-25 10:52:06 +02:00
Samuel Enocsson f2e30c62aa fix: zero excluded count in fallback, drop debug-icon orphan, align ejs guard (#21) 2026-05-25 09:44:46 +02:00
Samuel Enocsson 0beeb98002 feat: show excluded rounds count in player history accordion (#21) 2026-05-25 09:34:42 +02:00
13 changed files with 211 additions and 193 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pdga-ratings",
"version": "1.2.11",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pdga-ratings",
"version": "1.2.11",
"version": "1.4.0",
"dependencies": {
"ejs": "^4.0.1",
"express": "^4.18.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pdga-ratings",
"version": "1.2.11",
"version": "1.4.0",
"description": "PDGA rating scraper and display",
"main": "server.js",
"scripts": {
-76
View File
@@ -94,82 +94,6 @@
box-shadow: var(--shadow-lg);
}
/* ── Debug Icon ───────────────────────────────── */
.debug-icon:hover {
opacity: 1 !important;
color: var(--accent) !important;
}
/* ── Debug Modal ──────────────────────────────── */
.debug-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(4px);
z-index: 10001;
display: none;
justify-content: center;
align-items: center;
}
.debug-content {
background: var(--surface-1);
border-radius: var(--radius-lg);
padding: 24px;
max-width: 640px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-overlay);
position: relative;
}
.debug-header {
font-weight: 700;
font-size: 16px;
margin-bottom: 16px;
color: var(--text-primary);
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.debug-log {
font-family: var(--font-mono);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
font-size: 12px;
line-height: 1.6;
white-space: pre-line;
color: var(--text-primary);
}
.debug-close {
position: absolute;
top: 12px;
right: 16px;
font-size: 22px;
color: var(--text-muted);
cursor: pointer;
background: none;
border: none;
padding: 4px;
border-radius: var(--radius-sm);
transition: color var(--transition), background var(--transition);
line-height: 1;
}
.debug-close:hover {
color: var(--text-primary);
background: var(--surface-3);
}
/* ── Add Player Modal ─────────────────────────── */
.modal {
-43
View File
@@ -1,4 +1,3 @@
const cachedDebugInfo = {};
let pendingPlayerData = null;
let openPdgaNumber = null;
@@ -186,10 +185,6 @@ async function refreshRoundHistory(pdgaNumber) {
}
if (data.success) {
if (data.debugLog) {
cachedDebugInfo[pdgaNumber] = data.debugLog;
}
const predictedCell = document.getElementById(`predicted-${pdgaNumber}`);
if (predictedCell) {
const predictedValue = predictedCell.querySelector('.predicted-value');
@@ -234,43 +229,6 @@ async function refreshRatingHistory(pdgaNumber) {
}
}
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');
@@ -712,7 +670,6 @@ async function refreshHistoryThenCalculate(pdgaNumber) {
return;
}
if (data.debugLog) cachedDebugInfo[pdgaNumber] = data.debugLog;
const predictedCell = document.getElementById('predicted-' + pdgaNumber);
if (predictedCell) {
const predictedValue = predictedCell.querySelector('.predicted-value');
+18
View File
@@ -34,6 +34,8 @@ function initializeDatabase() {
const hasLastRoundUpdate = columns.some(col => col.name === 'last_round_update');
const hasPredictedRating = columns.some(col => col.name === 'predicted_rating');
const hasStdDev = columns.some(col => col.name === 'std_dev');
const hasExcludedRoundsCount = columns.some(col => col.name === 'excluded_rounds_count');
const hasCutoffRating = columns.some(col => col.name === 'cutoff_rating');
if (!hasLastRoundUpdate) {
logger.info('Adding last_round_update column to players table...');
@@ -58,6 +60,22 @@ function initializeDatabase() {
else logger.info('Successfully added std_dev column');
});
}
if (!hasExcludedRoundsCount) {
logger.info('Adding excluded_rounds_count column to players table...');
db.run(`ALTER TABLE players ADD COLUMN excluded_rounds_count INTEGER DEFAULT NULL`, (err) => {
if (err) logger.error('Error adding excluded_rounds_count column:', err.message);
else logger.info('Successfully added excluded_rounds_count column');
});
}
if (!hasCutoffRating) {
logger.info('Adding cutoff_rating column to players table...');
db.run(`ALTER TABLE players ADD COLUMN cutoff_rating INTEGER DEFAULT NULL`, (err) => {
if (err) logger.error('Error adding cutoff_rating column:', err.message);
else logger.info('Successfully added cutoff_rating column');
});
}
});
});
+23 -9
View File
@@ -1,5 +1,6 @@
const { db } = require('../db');
const { parseDate } = require('../services/rating-calculator');
const logger = require('../logger');
function getPlayerFromDB(pdgaNumber) {
return new Promise((resolve, reject) => {
@@ -16,6 +17,7 @@ function getPlayerFromDB(pdgaNumber) {
function savePlayerToDB(playerData) {
return new Promise((resolve, reject) => {
logger.info({ pdgaNumber: playerData.pdgaNumber, name: playerData.name, currentRating: playerData.rating, ratingChange: playerData.ratingChange, resetColumns: ['predicted_rating', 'std_dev', 'excluded_rounds_count', 'cutoff_rating', 'last_round_update'] }, 'INSERT OR REPLACE on players — derived columns will be reset to NULL');
db.run(
`INSERT OR REPLACE INTO players (pdga_number, name, current_rating, rating_change, last_updated)
VALUES (?, ?, ?, ?, datetime('now'))`,
@@ -172,16 +174,28 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
});
}
function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) {
function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null, excludedRoundsCount = null, cutoffRating = null, callsite = 'unknown') {
return new Promise((resolve, reject) => {
db.run(
'UPDATE players SET predicted_rating = ?, std_dev = ? WHERE pdga_number = ?',
[predictedRating, stdDev, pdgaNumber],
function(err) {
if (err) reject(err);
else resolve();
}
);
db.get('SELECT predicted_rating AS oldValue FROM players WHERE pdga_number = ?', [pdgaNumber], (selectErr, row) => {
if (selectErr) return reject(selectErr);
const oldValue = row ? row.oldValue : null;
db.run(
'UPDATE players SET predicted_rating = ?, std_dev = ?, excluded_rounds_count = ?, cutoff_rating = ? WHERE pdga_number = ?',
[predictedRating, stdDev, excludedRoundsCount, cutoffRating, pdgaNumber],
function(err) {
if (err) return reject(err);
const logData = { pdgaNumber, oldValue, newValue: predictedRating, callsite };
if (predictedRating === 0 && oldValue != null && oldValue > 0) {
logger.warn(logData, 'predicted rating overwritten with 0');
} else {
logger.info(logData, 'predicted rating saved');
}
resolve();
}
);
});
});
}
+66 -28
View File
@@ -7,7 +7,7 @@ const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrap
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 { calculatePredictedRating, getNextPDGAUpdateDate } = require('../services/rating-calculator');
const { calculateRequiredAverage } = require('../services/target-rating-calculator');
const logger = require('../logger');
@@ -19,6 +19,7 @@ router.post('/api/refresh-all', async (req, res, next) => {
return res.status(409).json({ error: 'Refresh already in progress' });
}
refreshInProgress = true;
logger.info({ pdgaNumber: 'all' }, 'refresh-all triggered — will invoke savePlayerToDB for all players (INSERT OR REPLACE wipes derived columns)');
try {
try {
await refreshAllPlayersInDB();
@@ -253,7 +254,7 @@ router.post('/api/add-player', async (req, res) => {
router.post('/api/refresh-player/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
logger.info(`Manually refreshing player data for PDGA ${pdgaNumber}`);
logger.info({ pdgaNumber }, 'refresh-player triggered — will invoke savePlayerToDB (INSERT OR REPLACE wipes derived columns)');
const html = await fetchPlayerDataHTTP(pdgaNumber);
const playerData = parsePlayerData(html, pdgaNumber);
@@ -349,48 +350,45 @@ router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
const isIncremental = !!sinceDate;
logger.info(`${isIncremental ? 'Incrementally updating' : 'Fully refreshing'} round history for PDGA ${pdgaNumber}${sinceDate ? ` since ${sinceDate.toDateString()}` : ''}`);
logger.info({ pdgaNumber, lastRoundUpdate, isIncremental }, 'refresh-round-history started');
browser = await launchBrowser();
let officialHistory;
try {
officialHistory = await getOfficialRatingHistory(browser, pdgaNumber);
if (officialHistory.length > 0) {
await saveRatingHistoryToDB(pdgaNumber, officialHistory);
}
logger.info({ pdgaNumber, step: 'official_history_scrape', success: officialHistory.length > 0, count: officialHistory.length }, 'official history scrape completed');
} catch (historyError) {
logger.error('Failed to fetch official history:', historyError.message);
logger.warn({ pdgaNumber, step: 'official_history_scrape', success: false, error: historyError.message }, 'official history scrape failed');
officialHistory = [];
}
let allRounds = [];
try {
logger.info(`Using optimized approach: /details + new tournaments only for PDGA ${pdgaNumber}...`);
allRounds = await getOptimizedPlayerRounds(browser, pdgaNumber);
if (allRounds.length > 0) {
const roundsForDB = allRounds.map(round => ({
rating: round.rating,
date: round.date,
competition: round.competition
}));
await saveRoundHistoryToDB(pdgaNumber, roundsForDB, false);
logger.info(`✓ Saved ${allRounds.length} rounds using optimized approach`);
await updateLastRoundUpdateDate(pdgaNumber);
} else {
logger.info(' No rounds found');
}
logger.info({ pdgaNumber, step: 'rounds_scrape_save', success: allRounds.length > 0, count: allRounds.length, lastRoundUpdateTouched: allRounds.length > 0 }, 'rounds scrape and save completed');
} catch (detailsError) {
logger.error('Failed to fetch rounds using optimized approach:', detailsError.message);
logger.warn({ pdgaNumber, step: 'rounds_scrape_save', success: false, error: detailsError.message }, 'rounds scrape and save failed');
allRounds = [];
}
await browser.close();
browser = null;
const dbRounds = await getRoundHistoryFromDB(pdgaNumber);
const roundsForPrediction = dbRounds.map(round => ({
rating: round.rating,
@@ -398,9 +396,10 @@ router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
competition: round.competition_name
}));
const result = calculatePredictedRating(roundsForPrediction);
const result = calculatePredictedRating(roundsForPrediction, { pdgaNumber, callsite: 'refresh-round-history' });
logger.info({ pdgaNumber, step: 'predicted_calc', rating: result.rating, stdDev: result.stdDev, excludedRoundsCount: result.excludedRoundsCount }, 'predicted rating calculation step completed');
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev, result.excludedRoundsCount, result.cutoffRating, 'refresh-round-history');
const officialCount = allRounds.filter(r => r.source === 'official').length;
const newCount = allRounds.filter(r => r.source === 'new').length;
@@ -409,7 +408,8 @@ router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
success: true,
predictedRating: result.rating,
stdDev: result.stdDev,
debugLog: result.debugLog,
excludedRoundsCount: result.excludedRoundsCount,
cutoffRating: result.cutoffRating,
totalRounds: roundsForPrediction.length,
officialRounds: officialCount,
newRounds: newCount,
@@ -417,10 +417,8 @@ router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
message: `Used /details (${officialCount} rounds) + new tournaments (${newCount} rounds)`
});
} catch (error) {
logger.error(`=== Error refreshing round history for PDGA ${pdgaNumber} ===`);
logger.error('Error type:', error.constructor.name);
logger.error('Error message:', error.message);
logger.error({ pdgaNumber, step: 'unknown_or_orchestration', errorType: error.constructor.name, errorMessage: error.message }, 'refresh-round-history failed');
if (browser) {
try {
await browser.close();
@@ -428,14 +426,14 @@ router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
logger.error('Error closing browser:', closeError.message);
}
}
res.status(500).json({
res.status(500).json({
error: 'Failed to refresh round history',
details: error.message,
errorType: error.constructor.name,
timestamp: new Date().toISOString(),
suggestion: error.message.includes('socket hang up') ?
'Rate limited by PDGA - try again in a few minutes.' :
suggestion: error.message.includes('socket hang up') ?
'Rate limited by PDGA - try again in a few minutes.' :
error.message.includes('timeout') ?
'PDGA pages are loading slowly - try again later.' :
'Tournament scraping failed - check server logs for details'
@@ -443,6 +441,46 @@ router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
}
});
router.get('/api/admin/player-state/:pdgaNumber', async (req, res) => {
const { pdgaNumber } = req.params;
try {
const player = await getPlayerFromDB(pdgaNumber);
if (!player) {
return res.status(404).json({ error: 'Player not found', pdgaNumber: parseInt(pdgaNumber) });
}
const rounds = await getRoundHistoryFromDB(pdgaNumber);
const cutoff = getNextPDGAUpdateDate();
const twelveMonthsAgo = new Date(cutoff); twelveMonthsAgo.setFullYear(cutoff.getFullYear() - 1);
const twentyFourMonthsAgo = new Date(cutoff); twentyFourMonthsAgo.setFullYear(cutoff.getFullYear() - 2);
const roundsInLast12mo = rounds.filter(r => new Date(r.date) >= twelveMonthsAgo).length;
const roundsInLast24mo = rounds.filter(r => new Date(r.date) >= twentyFourMonthsAgo).length;
const dates = rounds.map(r => r.date).sort();
res.json({
pdgaNumber: player.pdga_number,
name: player.name,
currentRating: player.current_rating,
ratingChange: player.rating_change,
predictedRating: player.predicted_rating,
stdDev: player.std_dev,
excludedRoundsCount: player.excluded_rounds_count,
cutoffRating: player.cutoff_rating,
lastUpdated: player.last_updated,
lastRoundUpdate: player.last_round_update,
cutoffDate: cutoff.toISOString(),
roundCount: rounds.length,
roundsInLast12mo,
roundsInLast24mo,
oldestRound: dates[0] ?? null,
newestRound: dates[dates.length - 1] ?? null
});
} catch (err) {
logger.error({ err, pdgaNumber }, 'admin player-state endpoint failed');
res.status(500).json({ error: 'Failed to fetch player state', details: err.message });
}
});
router.post('/api/calculate-target-rating/:pdgaNumber', async (req, res) => {
const { pdgaNumber } = req.params;
const pdgaNum = parseInt(pdgaNumber, 10);
@@ -482,7 +520,7 @@ router.post('/api/calculate-target-rating/:pdgaNumber', async (req, res) => {
competition: r.competition_name
}));
const result = calculateRequiredAverage(roundRatings, target, numRounds);
const result = calculateRequiredAverage(roundRatings, target, numRounds, pdgaNum);
logger.info(`Target rating calc for PDGA ${pdgaNum}: target=${target} rounds=${numRounds} -> avg=${result.requiredAverage}`);
+40 -16
View File
@@ -36,18 +36,32 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true }
try {
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
if (cachedPlayer) {
logger.debug(`Loading PDGA ${pdgaNumber} from DB (source of truth)`);
logger.debug({ pdgaNumber }, 'Loading player from DB (source of truth)');
let predictedRating = cachedPlayer.predicted_rating;
let stdDev = cachedPlayer.std_dev;
let excludedRoundsCount = cachedPlayer.excluded_rounds_count;
let cutoffRating = cachedPlayer.cutoff_rating;
let recomputeAttempted = false;
if (!predictedRating || predictedRating === 0) {
recomputeAttempted = true;
logger.debug({ pdgaNumber, dbValue: cachedPlayer.predicted_rating }, 'lazy recompute triggered for predicted_rating');
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
stdDev = updatedPlayer?.std_dev;
excludedRoundsCount = updatedPlayer?.excluded_rounds_count;
cutoffRating = updatedPlayer?.cutoff_rating;
if (!predictedRating || predictedRating === 0) {
logger.info({ pdgaNumber, recomputedValue: predictedRating }, 'lazy recompute did not yield a positive predicted rating');
}
}
const rating = cachedPlayer.current_rating;
const rawRatingChange = cachedPlayer.rating_change;
// Only warn about ≤0 if it wasn't already explained by a failed recompute (which has its own log)
if (!recomputeAttempted && predictedRating != null && predictedRating <= 0) {
logger.warn({ pdgaNumber, dbValue: predictedRating }, 'predicted rating present but <= 0 in DB without recompute — rendered as empty');
}
const resolvedPredicted = predictedRating > 0 ? predictedRating : null;
const resolvedStdDev = stdDev > 0 ? stdDev : null;
@@ -65,6 +79,8 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true }
ratingChange,
predictedRating: resolvedPredicted,
stdDev: resolvedStdDev,
excludedRoundsCount: (excludedRoundsCount != null && excludedRoundsCount >= 0) ? excludedRoundsCount : null,
cutoffRating: (cutoffRating != null && cutoffRating > 0) ? cutoffRating : null,
lastMonthRating,
// gap between next predicted update and current rating (null when either is missing)
deltaPredicted: (resolvedPredicted != null && rating != null) ? resolvedPredicted - rating : null,
@@ -134,22 +150,28 @@ async function scrapePDGARating(pdgaNumber, retries = 3) {
async function getPredictedRatingFromDB(pdgaNumber) {
try {
const roundHistory = await getRoundHistoryFromDB(pdgaNumber);
if (roundHistory.length > 0) {
logger.debug(`Using ${roundHistory.length} cached rounds for PDGA ${pdgaNumber} prediction`);
const roundRatings = roundHistory.map(round => ({
rating: round.rating,
date: new Date(round.date),
competition: round.competition_name || 'Unknown'
}));
const result = calculatePredictedRating(roundRatings);
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
return result.rating;
if (roundHistory.length === 0) {
logger.info({ pdgaNumber, reason: 'no_round_history_in_db' }, 'predicted recompute returning 0 — no round history available');
return 0;
}
return 0;
logger.debug({ pdgaNumber, cachedRounds: roundHistory.length }, 'Using cached rounds for prediction');
const roundRatings = roundHistory.map(round => ({
rating: round.rating,
date: new Date(round.date),
competition: round.competition_name || 'Unknown'
}));
const result = calculatePredictedRating(roundRatings, { pdgaNumber, callsite: 'getPredictedRatingFromDB' });
if (result.rating === 0) {
logger.warn({ pdgaNumber, roundsInDb: roundHistory.length }, 'predicted recompute returned 0 despite having rounds in DB');
}
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev, result.excludedRoundsCount, result.cutoffRating, 'lazy-recompute');
return result.rating;
} catch (err) {
logger.error(`Error getting predicted rating from DB for ${pdgaNumber}:`, err.message);
return 0;
@@ -229,6 +251,8 @@ async function getAllRatingsFromDB(progressCallback = null) {
ratingChange: errorRatingChange,
predictedRating: null,
stdDev: null,
excludedRoundsCount: null,
cutoffRating: null,
lastMonthRating: (errorRating != null && errorRatingChange != null) ? errorRating - errorRatingChange : null,
deltaPredicted: null,
monthlyHistory: [],
+35 -6
View File
@@ -1,3 +1,5 @@
const logger = require('../logger');
function parseDate(dateStr) {
const multiDayMatch = dateStr.match(/^(\d{1,2})(-([A-Za-z]{3}))?(\s+to\s+)(\d{1,2})-([A-Za-z]{3})-(\d{4})$/);
if (multiDayMatch) {
@@ -79,15 +81,19 @@ function calculateStandardDeviation(ratings) {
return Math.sqrt(variance);
}
function calculatePredictedRating(roundRatings) {
function calculatePredictedRating(roundRatings, context = {}) {
const pdgaNumber = context.pdgaNumber ?? null;
const callsite = context.callsite ?? 'unknown';
const debugLog = [];
debugLog.push('=== PDGA RATING CALCULATION (Following Official Rules) ===');
if (!roundRatings || roundRatings.length === 0) {
debugLog.push('❌ No rounds provided for prediction');
return { rating: 0, debugLog };
logger.warn({ pdgaNumber, callsite, reason: 'no_rounds' }, 'predicted rating computed as 0');
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
}
logger.info({ pdgaNumber, callsite, inputRounds: roundRatings.length }, 'predicted rating calculation started');
debugLog.push(`📊 Starting with ${roundRatings.length} total rounds`);
const nextUpdateDate = getNextPDGAUpdateDate();
@@ -100,7 +106,8 @@ function calculatePredictedRating(roundRatings) {
if (allSortedRounds.length === 0) {
debugLog.push('❌ No valid rounds after filtering for update date');
return { rating: 0, debugLog };
logger.warn({ pdgaNumber, callsite, reason: 'no_rounds_after_date_filter', inputRounds: roundRatings.length, nextUpdateDate: nextUpdateDate.toISOString() }, 'predicted rating computed as 0');
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
}
debugLog.push(`📊 After update date filter: ${allSortedRounds.length} rounds`);
@@ -117,8 +124,9 @@ function calculatePredictedRating(roundRatings) {
debugLog.push('🗓️ 12-MONTH FILTERING:');
debugLog.push(`✅ Rounds in last 12 months: ${eligibleRounds.length}`);
let twentyFourMonthsBeforeUpdate = null;
if (eligibleRounds.length < 8) {
const twentyFourMonthsBeforeUpdate = new Date(nextUpdateDate);
twentyFourMonthsBeforeUpdate = new Date(nextUpdateDate);
twentyFourMonthsBeforeUpdate.setFullYear(twentyFourMonthsBeforeUpdate.getFullYear() - 2);
eligibleRounds = allSortedRounds.filter(r => r.date >= twentyFourMonthsBeforeUpdate);
@@ -127,7 +135,8 @@ function calculatePredictedRating(roundRatings) {
if (eligibleRounds.length === 0) {
debugLog.push('❌ No eligible rounds found');
return { rating: 0, debugLog };
logger.warn({ pdgaNumber, callsite, reason: 'no_eligible_rounds', afterDateFilter: allSortedRounds.length, twelveMonthCutoff: twelveMonthsBeforeUpdate.toISOString(), twentyFourMonthCutoff: twentyFourMonthsBeforeUpdate ? twentyFourMonthsBeforeUpdate.toISOString() : null }, 'predicted rating computed as 0');
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
}
debugLog.push(`📈 ELIGIBLE ROUNDS: ${eligibleRounds.length}`);
@@ -137,6 +146,8 @@ function calculatePredictedRating(roundRatings) {
let workingRounds = [...eligibleRounds];
let workingRatings = workingRounds.map(r => r.rating);
let excludedRoundsCount = 0;
let cutoffRating = null;
if (workingRatings.length >= 7) {
debugLog.push('🔍 OUTLIER EXCLUSION (≥7 rounds available):');
@@ -160,6 +171,9 @@ function calculatePredictedRating(roundRatings) {
const stdDevOutliers = workingRatings.filter(rating => rating < stdDevCutoff);
const hundredPointOutliers = workingRatings.filter(rating => rating < hundredPointCutoff && rating >= stdDevCutoff);
excludedRoundsCount = stdDevOutliers.length + hundredPointOutliers.length;
cutoffRating = Math.round(Math.max(stdDevCutoff, hundredPointCutoff));
if (stdDevOutliers.length > 0) {
debugLog.push(` ❌ 2.5σ outliers removed: ${stdDevOutliers.length} rounds`);
stdDevOutliers.forEach(rating => {
@@ -188,6 +202,8 @@ function calculatePredictedRating(roundRatings) {
debugLog.push(` ✅ Using ${filteredRatings.length} rounds after outlier removal`);
} else {
debugLog.push(` ⚠️ Too few rounds after outlier removal (${filteredRatings.length}), keeping all rounds`);
excludedRoundsCount = 0;
cutoffRating = null;
}
} else {
debugLog.push(`⏭️ OUTLIER EXCLUSION SKIPPED (only ${workingRatings.length} rounds, need ≥7)`);
@@ -228,7 +244,20 @@ function calculatePredictedRating(roundRatings) {
debugLog.push(` Final Rating: ${finalRating}`);
debugLog.push('=== END PDGA CALCULATION ===');
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog };
logger.info({
pdgaNumber,
callsite,
finalRating,
inputRounds: roundRatings.length,
afterDateFilter: allSortedRounds.length,
eligibleRounds: eligibleRounds.length,
outlierExclusionApplied: workingRatings.length >= 7,
doubleWeightingApplied: workingRatings.length >= 9,
excludedRoundsCount,
cutoffRating
}, 'predicted rating calculated');
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog, excludedRoundsCount, cutoffRating };
}
module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };
+2 -2
View File
@@ -1,14 +1,14 @@
const { calculatePredictedRating, getNextPDGAUpdateDate } = require('./rating-calculator');
const logger = require('../logger');
function calculateRequiredAverage(roundRatings, targetRating, numRounds) {
function calculateRequiredAverage(roundRatings, targetRating, numRounds, pdgaNumber) {
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 currentPredicted = calculatePredictedRating(roundRatings, { pdgaNumber, callsite: 'target-rating-calculator' }).rating;
const nextUpdate = getNextPDGAUpdateDate();
const syntheticDate = new Date(nextUpdate.getTime() - 24 * 60 * 60 * 1000);
-9
View File
@@ -95,15 +95,6 @@
`; %>
<% var modals = `
<!-- Debug Modal -->
<div id="debug-modal" class="debug-modal" onclick="closeDebugModal(event)">
<div class="debug-content" onclick="event.stopPropagation()">
<button class="debug-close" onclick="closeDebugModal()">&times;</button>
<div class="debug-header" id="debug-header">Prediction Calculation Details</div>
<div class="debug-log" id="debug-log">Loading...</div>
</div>
</div>
<!-- Add Player Confirmation Modal -->
<div id="add-player-modal" class="modal" onclick="closeAddPlayerModal(event)">
<div class="modal-content" onclick="event.stopPropagation()">
+12 -1
View File
@@ -36,8 +36,19 @@ const chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber;
<dd><%= player.rating - player.stdDev %><%= player.rating + player.stdDev %></dd>
</div>
<% } %>
<% if (player.excludedRoundsCount != null && player.rating) { %>
<div>
<dt>Excluded rounds</dt>
<dd><%= player.excludedRoundsCount %></dd>
</div>
<% } %>
<% if (player.cutoffRating != null && player.rating) { %>
<div>
<dt>Cutoff rating</dt>
<dd><%= player.cutoffRating %></dd>
</div>
<% } %>
</dl>
<button class="link-btn" onclick="showDebugInfo(<%= player.pdgaNumber %>)" style="margin-top: 4px;">View calculation details →</button>
</div>
<% } %>
+12
View File
@@ -124,6 +124,18 @@ function renderSparkline(values, opts) {
<dd><%= player.rating - player.stdDev %><%= player.rating + player.stdDev %></dd>
</div>
<% } %>
<% if (player.excludedRoundsCount != null && player.rating) { %>
<div>
<dt>Excluded rounds</dt>
<dd><%= player.excludedRoundsCount %></dd>
</div>
<% } %>
<% if (player.cutoffRating != null && player.rating) { %>
<div>
<dt>Cutoff rating</dt>
<dd><%= player.cutoffRating %></dd>
</div>
<% } %>
</dl>
</div>
</div>