From 27ffa096e4fec80479b049df49bea2b87c314c18 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 9 Jun 2026 10:58:42 +0200 Subject: [PATCH 1/2] fix: invalidate stale predicted_rating after PDGA cycle rollover (#29) --- src/db.js | 10 +++++++ src/models/player.js | 7 +++-- src/services/player-service.js | 21 +++++++++++++- src/services/rating-calculator.js | 48 +++++++++++++++++-------------- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/db.js b/src/db.js index f91061a..0c9da7e 100644 --- a/src/db.js +++ b/src/db.js @@ -76,6 +76,16 @@ function initializeDatabase() { else logger.info('Successfully added cutoff_rating column'); }); } + + const hasPredictedCalculatedAt = columns.some(col => col.name === 'predicted_calculated_at'); + + if (!hasPredictedCalculatedAt) { + logger.info('Adding predicted_calculated_at column to players table...'); + db.run(`ALTER TABLE players ADD COLUMN predicted_calculated_at DATETIME DEFAULT NULL`, (err) => { + if (err) logger.error('Error adding predicted_calculated_at column:', err.message); + else logger.info('Successfully added predicted_calculated_at column'); + }); + } }); }); diff --git a/src/models/player.js b/src/models/player.js index 5f3f2c7..4191571 100644 --- a/src/models/player.js +++ b/src/models/player.js @@ -187,11 +187,12 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) { }); } -function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null, excludedRoundsCount = null, cutoffRating = null) { +function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null, excludedRoundsCount = null, cutoffRating = null, calculatedAt = null) { + const timestamp = calculatedAt || new Date().toISOString(); return new Promise((resolve, reject) => { db.run( - 'UPDATE players SET predicted_rating = ?, std_dev = ?, excluded_rounds_count = ?, cutoff_rating = ? WHERE pdga_number = ?', - [predictedRating, stdDev, excludedRoundsCount, cutoffRating, pdgaNumber], + 'UPDATE players SET predicted_rating = ?, std_dev = ?, excluded_rounds_count = ?, cutoff_rating = ?, predicted_calculated_at = ? WHERE pdga_number = ?', + [predictedRating, stdDev, excludedRoundsCount, cutoffRating, timestamp, pdgaNumber], function(err) { if (err) reject(err); else resolve(); diff --git a/src/services/player-service.js b/src/services/player-service.js index 25bcb7d..567c308 100644 --- a/src/services/player-service.js +++ b/src/services/player-service.js @@ -1,7 +1,7 @@ const { db } = require('../db'); const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB, getAllRatingHistoriesFromDB } = require('../models/player'); const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http'); -const { calculatePredictedRating } = require('./rating-calculator'); +const { calculatePredictedRating, getPreviousPDGAUpdateDate } = require('./rating-calculator'); const logger = require('../logger'); function formatDisplayDate(dateStr) { @@ -50,6 +50,25 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } cutoffRating = updatedPlayer?.cutoff_rating; } + // Staleness-check: invalidate cached predicted_rating if the PDGA cycle has + // rolled over since it was calculated. Don't recompute — round_history may be + // equally stale. UI will show "—" until the next manual refresh. + const predictedCalculatedAt = cachedPlayer.predicted_calculated_at + ? new Date(cachedPlayer.predicted_calculated_at) + : null; + const previousUpdate = getPreviousPDGAUpdateDate(); + const hasPredicted = predictedRating !== null && predictedRating !== 0; + const isStale = hasPredicted && ( + predictedCalculatedAt === null || predictedCalculatedAt < previousUpdate + ); + if (isStale) { + logger.debug(`PDGA ${pdgaNumber}: predicted rating stale (calculated ${predictedCalculatedAt?.toISOString() ?? 'unknown'}, cycle rolled ${previousUpdate.toISOString()})`); + predictedRating = null; + stdDev = null; + excludedRoundsCount = null; + cutoffRating = null; + } + const rating = cachedPlayer.current_rating; const rawRatingChange = cachedPlayer.rating_change; const resolvedPredicted = predictedRating > 0 ? predictedRating : null; diff --git a/src/services/rating-calculator.js b/src/services/rating-calculator.js index 576427f..4712b9e 100644 --- a/src/services/rating-calculator.js +++ b/src/services/rating-calculator.js @@ -37,39 +37,43 @@ function parseDate(dateStr) { return new Date(dateStr); } +function secondTuesdayOf(year, month) { + const firstDay = new Date(year, month, 1); + const daysUntilTuesday = (2 - firstDay.getDay() + 7) % 7; + const firstTuesday = new Date(year, month, 1 + daysUntilTuesday); + const secondTuesday = new Date(firstTuesday); + secondTuesday.setDate(firstTuesday.getDate() + 7); + return secondTuesday; +} + function getNextPDGAUpdateDate() { const today = new Date(); const currentMonth = today.getMonth(); const currentYear = today.getFullYear(); - const firstDayOfMonth = new Date(currentYear, currentMonth, 1); - const firstTuesday = new Date(firstDayOfMonth); + const thisMonths = secondTuesdayOf(currentYear, currentMonth); - const daysUntilTuesday = (2 - firstDayOfMonth.getDay() + 7) % 7; - firstTuesday.setDate(1 + daysUntilTuesday); - - const secondTuesday = new Date(firstTuesday); - secondTuesday.setDate(firstTuesday.getDate() + 7); - - if (today <= secondTuesday) { - return secondTuesday; + if (today <= thisMonths) { + return thisMonths; } else { const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1; const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear; - - const firstDayNextMonth = new Date(nextYear, nextMonth, 1); - const firstTuesdayNext = new Date(firstDayNextMonth); - - const daysUntilTuesdayNext = (2 - firstDayNextMonth.getDay() + 7) % 7; - firstTuesdayNext.setDate(1 + daysUntilTuesdayNext); - - const secondTuesdayNext = new Date(firstTuesdayNext); - secondTuesdayNext.setDate(firstTuesdayNext.getDate() + 7); - - return secondTuesdayNext; + return secondTuesdayOf(nextYear, nextMonth); } } +function getPreviousPDGAUpdateDate() { + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth(); + const thisMonths = secondTuesdayOf(year, month); + if (today > thisMonths) return thisMonths; + // Otherwise: last month's second Tuesday + const prevMonth = month === 0 ? 11 : month - 1; + const prevYear = month === 0 ? year - 1 : year; + return secondTuesdayOf(prevYear, prevMonth); +} + function calculateStandardDeviation(ratings) { if (!ratings || ratings.length === 0) return 0; @@ -238,4 +242,4 @@ function calculatePredictedRating(roundRatings) { return { rating: finalRating, stdDev: Math.round(stdDev), debugLog, excludedRoundsCount, cutoffRating }; } -module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation }; +module.exports = { parseDate, getNextPDGAUpdateDate, getPreviousPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation }; From c7fb4a70681044cbeb45396dca323aded6a0b99d Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 9 Jun 2026 11:04:53 +0200 Subject: [PATCH 2/2] fix: use re-fetched timestamp after recompute + rename helper var (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer 1 flagged: staleness-check read predicted_calculated_at from the original cachedPlayer snapshot even after recompute, so newly calculated ratings (predicted_calculated_at = NULL in snapshot) were immediately nulled by the staleness branch. Fix: read predicted_calculated_at from updatedPlayer too. Reviewer 2 nit: rename thisMonths → secondTuesday for consistency with the original variable name in getNextPDGAUpdateDate. --- src/services/player-service.js | 6 ++++-- src/services/rating-calculator.js | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/services/player-service.js b/src/services/player-service.js index 567c308..8465754 100644 --- a/src/services/player-service.js +++ b/src/services/player-service.js @@ -42,19 +42,21 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } let stdDev = cachedPlayer.std_dev; let excludedRoundsCount = cachedPlayer.excluded_rounds_count; let cutoffRating = cachedPlayer.cutoff_rating; + let predictedCalculatedAtRaw = cachedPlayer.predicted_calculated_at; if (!predictedRating || predictedRating === 0) { predictedRating = await getPredictedRatingFromDB(pdgaNumber); const updatedPlayer = await getPlayerFromDB(pdgaNumber); stdDev = updatedPlayer?.std_dev; excludedRoundsCount = updatedPlayer?.excluded_rounds_count; cutoffRating = updatedPlayer?.cutoff_rating; + predictedCalculatedAtRaw = updatedPlayer?.predicted_calculated_at; } // Staleness-check: invalidate cached predicted_rating if the PDGA cycle has // rolled over since it was calculated. Don't recompute — round_history may be // equally stale. UI will show "—" until the next manual refresh. - const predictedCalculatedAt = cachedPlayer.predicted_calculated_at - ? new Date(cachedPlayer.predicted_calculated_at) + const predictedCalculatedAt = predictedCalculatedAtRaw + ? new Date(predictedCalculatedAtRaw) : null; const previousUpdate = getPreviousPDGAUpdateDate(); const hasPredicted = predictedRating !== null && predictedRating !== 0; diff --git a/src/services/rating-calculator.js b/src/services/rating-calculator.js index 4712b9e..77c6e13 100644 --- a/src/services/rating-calculator.js +++ b/src/services/rating-calculator.js @@ -51,10 +51,10 @@ function getNextPDGAUpdateDate() { const currentMonth = today.getMonth(); const currentYear = today.getFullYear(); - const thisMonths = secondTuesdayOf(currentYear, currentMonth); + const secondTuesday = secondTuesdayOf(currentYear, currentMonth); - if (today <= thisMonths) { - return thisMonths; + if (today <= secondTuesday) { + return secondTuesday; } else { const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1; const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear; @@ -66,8 +66,8 @@ function getPreviousPDGAUpdateDate() { const today = new Date(); const year = today.getFullYear(); const month = today.getMonth(); - const thisMonths = secondTuesdayOf(year, month); - if (today > thisMonths) return thisMonths; + const secondTuesday = secondTuesdayOf(year, month); + if (today > secondTuesday) return secondTuesday; // Otherwise: last month's second Tuesday const prevMonth = month === 0 ? 11 : month - 1; const prevYear = month === 0 ? year - 1 : year;