Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3e33a5ea | |||
| c7fb4a7068 | |||
| 27ffa096e4 | |||
| 5198a1c0f4 | |||
| 7297c0a16b | |||
| ada2dcb4ae | |||
| 5ece854340 | |||
| 2ef7de4e58 | |||
| 16c045e7cc | |||
| 8ee5cc3861 | |||
| 2561ee12ef | |||
| 0d2f0fa3a8 | |||
| ec3ae872da |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pdga-ratings",
|
"name": "pdga-ratings",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pdga-ratings",
|
"name": "pdga-ratings",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "^4.0.1",
|
"ejs": "^4.0.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pdga-ratings",
|
"name": "pdga-ratings",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"description": "PDGA rating scraper and display",
|
"description": "PDGA rating scraper and display",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -342,6 +342,38 @@
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Refresh button: hidden by default, revealed only when the card is open.
|
||||||
|
Larger than the desktop icon to give a comfortable touch target (≥44px). */
|
||||||
|
.m-card__head .m-refresh-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-card.is-open .m-card__head .m-refresh-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin-left: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-card.is-open .m-card__head .m-refresh-icon:active {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spin only the icon glyph, not the 44px button box — otherwise the button's
|
||||||
|
lingering touch-hover frame (background + border) rotates too, which looks odd. */
|
||||||
|
.m-card.is-open .m-card__head .m-refresh-icon.spinning {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-card.is-open .m-card__head .m-refresh-icon.spinning i {
|
||||||
|
display: inline-block;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.m-card__body {
|
.m-card__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
|||||||
+10
-4
@@ -131,10 +131,16 @@ async function clearCache() {
|
|||||||
// Refreshes both the current rating and the prediction in one click, then
|
// Refreshes both the current rating and the prediction in one click, then
|
||||||
// re-swaps the table so every derived value (deltas, pills, sparkline) reflects
|
// re-swaps the table so every derived value (deltas, pills, sparkline) reflects
|
||||||
// the new state. Cheaper than fine-grained DOM updates and guaranteed consistent
|
// the new state. Cheaper than fine-grained DOM updates and guaranteed consistent
|
||||||
// because the server renders the truth.
|
// because the server renders the truth. The mobile cards partial is included
|
||||||
|
// inside ratings-table, so swapping #ratings-table re-renders both views at once.
|
||||||
async function refreshPlayerData(pdgaNumber) {
|
async function refreshPlayerData(pdgaNumber) {
|
||||||
const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`);
|
// The desktop row exists in the DOM even on mobile (hidden via CSS), so spin
|
||||||
if (icon) icon.classList.add('spinning');
|
// both possible icons; only the one visible in the active viewport is seen.
|
||||||
|
const icons = [
|
||||||
|
document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`),
|
||||||
|
document.querySelector(`#m-card-${pdgaNumber} .m-refresh-icon`)
|
||||||
|
].filter(Boolean);
|
||||||
|
icons.forEach(icon => icon.classList.add('spinning'));
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }),
|
fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }),
|
||||||
@@ -144,7 +150,7 @@ async function refreshPlayerData(pdgaNumber) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing player data:', error);
|
console.error('Error refreshing player data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
if (icon) icon.classList.remove('spinning');
|
icons.forEach(icon => icon.classList.remove('spinning'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ function initializeDatabase() {
|
|||||||
else logger.info('Successfully added cutoff_rating column');
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+23
-7
@@ -17,12 +17,27 @@ function getPlayerFromDB(pdgaNumber) {
|
|||||||
function savePlayerToDB(playerData) {
|
function savePlayerToDB(playerData) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR REPLACE INTO players (pdga_number, name, current_rating, rating_change, last_updated)
|
// UPSERT (not INSERT OR REPLACE): updating in place preserves columns not
|
||||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
// listed here — predicted_rating, std_dev, last_round_update,
|
||||||
|
// excluded_rounds_count, cutoff_rating. INSERT OR REPLACE would delete the
|
||||||
|
// existing row and reset those to their DEFAULT (NULL).
|
||||||
|
`INSERT INTO players (pdga_number, name, current_rating, rating_change, last_updated)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(pdga_number) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
current_rating = excluded.current_rating,
|
||||||
|
rating_change = excluded.rating_change,
|
||||||
|
last_updated = excluded.last_updated`,
|
||||||
[playerData.pdgaNumber, playerData.name, playerData.rating, playerData.ratingChange],
|
[playerData.pdgaNumber, playerData.name, playerData.rating, playerData.ratingChange],
|
||||||
function(err) {
|
function(err) {
|
||||||
if (err) reject(err);
|
if (err) return reject(err);
|
||||||
else resolve(this.lastID);
|
// node-sqlite3 leaves lastID = 0 when ON CONFLICT triggers an UPDATE.
|
||||||
|
// Fall back to a SELECT to get the real id in that case.
|
||||||
|
if (this.lastID !== 0) return resolve(this.lastID);
|
||||||
|
db.get('SELECT id FROM players WHERE pdga_number = ?', [playerData.pdgaNumber], (err2, row) => {
|
||||||
|
if (err2) reject(err2);
|
||||||
|
else resolve(row ? row.id : 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -172,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) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
'UPDATE players SET predicted_rating = ?, std_dev = ?, excluded_rounds_count = ?, cutoff_rating = ? WHERE pdga_number = ?',
|
'UPDATE players SET predicted_rating = ?, std_dev = ?, excluded_rounds_count = ?, cutoff_rating = ?, predicted_calculated_at = ? WHERE pdga_number = ?',
|
||||||
[predictedRating, stdDev, excludedRoundsCount, cutoffRating, pdgaNumber],
|
[predictedRating, stdDev, excludedRoundsCount, cutoffRating, timestamp, pdgaNumber],
|
||||||
function(err) {
|
function(err) {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
|
|||||||
@@ -156,81 +156,154 @@ async function getNewTournamentRounds(browser, pdgaNumber, afterDate) {
|
|||||||
|
|
||||||
logger.info(`Looking for tournaments after ${afterDate.toDateString()}...`);
|
logger.info(`Looking for tournaments after ${afterDate.toDateString()}...`);
|
||||||
|
|
||||||
const newTournamentUrls = await page.evaluate((afterTimestamp) => {
|
const { urls: newTournamentUrls, counts } = await page.evaluate((afterTimestamp) => {
|
||||||
const afterDate = new Date(afterTimestamp);
|
const afterDate = new Date(afterTimestamp);
|
||||||
const tables = document.querySelectorAll('table[id*="player-results"]');
|
const tables = document.querySelectorAll('table[id*="player-results"]');
|
||||||
const urls = [];
|
const urls = [];
|
||||||
|
const seenUrls = new Set();
|
||||||
tables.forEach(table => {
|
let table = 0;
|
||||||
const rows = table.querySelectorAll('tbody tr');
|
let recentEvents = 0;
|
||||||
|
let recentEventsAnchorsSeen = 0;
|
||||||
|
let recentEventsSkippedDuplicates = 0;
|
||||||
|
|
||||||
|
tables.forEach(tbl => {
|
||||||
|
const rows = tbl.querySelectorAll('tbody tr');
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const dateCell = row.querySelector('.dates');
|
const dateCell = row.querySelector('.dates');
|
||||||
const tournamentCell = row.querySelector('.tournament a');
|
const tournamentCell = row.querySelector('.tournament a');
|
||||||
|
|
||||||
if (dateCell && tournamentCell) {
|
if (dateCell && tournamentCell) {
|
||||||
const dateText = dateCell.innerText.trim();
|
const dateText = dateCell.innerText.trim();
|
||||||
const dateMatch = dateText.match(/\d{1,2}-[A-Za-z]{3}-\d{4}/);
|
const dateMatch = dateText.match(/\d{1,2}-[A-Za-z]{3}-\d{4}/);
|
||||||
|
|
||||||
if (dateMatch) {
|
if (dateMatch) {
|
||||||
const dateStr = dateMatch[0];
|
const dateStr = dateMatch[0];
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
|
||||||
if (date > afterDate) {
|
if (date > afterDate) {
|
||||||
const href = tournamentCell.getAttribute('href');
|
const href = tournamentCell.getAttribute('href');
|
||||||
if (href) {
|
if (href) {
|
||||||
urls.push({
|
const absoluteUrl = new URL(href, location.origin).href;
|
||||||
url: `https://www.pdga.com${href}`,
|
if (!seenUrls.has(absoluteUrl)) {
|
||||||
date: dateStr,
|
seenUrls.add(absoluteUrl);
|
||||||
name: tournamentCell.innerText.trim()
|
urls.push({
|
||||||
});
|
url: absoluteUrl,
|
||||||
|
date: dateStr,
|
||||||
|
name: tournamentCell.innerText.trim(),
|
||||||
|
source: 'table'
|
||||||
|
});
|
||||||
|
table++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return urls;
|
const recentAnchors = document.querySelectorAll('.recent-events a[href*="/tour/event/"]');
|
||||||
|
recentAnchors.forEach(anchor => {
|
||||||
|
recentEventsAnchorsSeen++;
|
||||||
|
const href = anchor.getAttribute('href');
|
||||||
|
if (href) {
|
||||||
|
const absoluteUrl = new URL(href, location.origin).href;
|
||||||
|
if (seenUrls.has(absoluteUrl)) {
|
||||||
|
recentEventsSkippedDuplicates++;
|
||||||
|
} else {
|
||||||
|
seenUrls.add(absoluteUrl);
|
||||||
|
urls.push({
|
||||||
|
url: absoluteUrl,
|
||||||
|
date: null,
|
||||||
|
name: anchor.innerText.trim() || 'Recent event',
|
||||||
|
source: 'recent-events'
|
||||||
|
});
|
||||||
|
recentEvents++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { urls, counts: { table, recentEvents, recentEventsAnchorsSeen, recentEventsSkippedDuplicates } };
|
||||||
}, afterDate.getTime());
|
}, afterDate.getTime());
|
||||||
|
|
||||||
logger.info(`Found ${newTournamentUrls.length} new tournaments after ${afterDate.toDateString()}`);
|
logger.info({
|
||||||
|
pdgaNumber,
|
||||||
|
afterDate: afterDate.toISOString(),
|
||||||
|
tableMatches: counts.table,
|
||||||
|
recentEventsMatches: counts.recentEvents,
|
||||||
|
recentEventsAnchorsSeen: counts.recentEventsAnchorsSeen,
|
||||||
|
recentEventsSkippedDuplicates: counts.recentEventsSkippedDuplicates,
|
||||||
|
totalUrlsToScrape: newTournamentUrls.length
|
||||||
|
}, 'new tournament URL discovery completed');
|
||||||
|
|
||||||
for (const tournamentData of newTournamentUrls) {
|
for (const tournamentData of newTournamentUrls) {
|
||||||
try {
|
try {
|
||||||
logger.info(`Scraping new tournament: ${tournamentData.name} (${tournamentData.date})`);
|
if (tournamentData.source === 'recent-events') {
|
||||||
|
logger.debug({ pdgaNumber, url: tournamentData.url }, 'recent-events: scraping tournament');
|
||||||
|
} else {
|
||||||
|
logger.info(`Scraping new tournament: ${tournamentData.name} (${tournamentData.date})`);
|
||||||
|
}
|
||||||
|
|
||||||
await page.goto(tournamentData.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
await page.goto(tournamentData.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
let parsedDate;
|
||||||
|
if (tournamentData.date !== null) {
|
||||||
|
parsedDate = parseDate(tournamentData.date);
|
||||||
|
} else {
|
||||||
|
const eventDateStr = await page.evaluate(() => {
|
||||||
|
const body = document.body ? document.body.innerText : '';
|
||||||
|
const m = body.match(/\d{1,2}\s+to\s+\d{1,2}-[A-Za-z]{3}-\d{4}/)
|
||||||
|
|| body.match(/\d{1,2}-[A-Za-z]{3}-\d{4}/);
|
||||||
|
return m ? m[0] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (eventDateStr) {
|
||||||
|
parsedDate = parseDate(eventDateStr);
|
||||||
|
if (!(parsedDate > afterDate)) {
|
||||||
|
logger.warn({
|
||||||
|
pdgaNumber,
|
||||||
|
url: tournamentData.url,
|
||||||
|
eventDateStr,
|
||||||
|
parsedDate: parsedDate ? parsedDate.toISOString() : null,
|
||||||
|
afterDate: afterDate.toISOString()
|
||||||
|
}, 'recent-events: extracted event date is not newer than afterDate, likely captured a non-tournament date — skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger.debug({ pdgaNumber, url: tournamentData.url, eventDateStr }, 'recent-events: extracted date from event page');
|
||||||
|
} else {
|
||||||
|
logger.warn({ pdgaNumber, url: tournamentData.url }, 'recent-events: could not extract date from event page, skipping tournament');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const roundRatings = await page.evaluate((pdgaNum) => {
|
const roundRatings = await page.evaluate((pdgaNum) => {
|
||||||
const rows = document.querySelectorAll('tr');
|
const rows = document.querySelectorAll('tr');
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const cells = row.querySelectorAll('td');
|
const cells = row.querySelectorAll('td');
|
||||||
const hasPlayerNumber = Array.from(cells).some(cell =>
|
const hasPlayerNumber = Array.from(cells).some(cell =>
|
||||||
cell.innerText && cell.innerText.includes(pdgaNum.toString())
|
cell.innerText && cell.innerText.includes(pdgaNum.toString())
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasPlayerNumber) {
|
if (hasPlayerNumber) {
|
||||||
const roundRatingCells = row.querySelectorAll('td.round-rating');
|
const roundRatingCells = row.querySelectorAll('td.round-rating');
|
||||||
const ratings = [];
|
const ratings = [];
|
||||||
|
|
||||||
roundRatingCells.forEach(cell => {
|
roundRatingCells.forEach(cell => {
|
||||||
const rating = parseInt(cell.innerText.trim());
|
const rating = parseInt(cell.innerText.trim());
|
||||||
if (!isNaN(rating) && rating > 0) {
|
if (!isNaN(rating) && rating > 0) {
|
||||||
ratings.push(rating);
|
ratings.push(rating);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return ratings;
|
return ratings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}, pdgaNumber);
|
}, pdgaNumber);
|
||||||
|
|
||||||
if (roundRatings.length > 0) {
|
if (roundRatings.length > 0) {
|
||||||
const parsedDate = parseDate(tournamentData.date);
|
|
||||||
roundRatings.forEach(rating => {
|
roundRatings.forEach(rating => {
|
||||||
newRounds.push({
|
newRounds.push({
|
||||||
rating,
|
rating,
|
||||||
@@ -238,10 +311,10 @@ async function getNewTournamentRounds(browser, pdgaNumber, afterDate) {
|
|||||||
competition: tournamentData.name
|
competition: tournamentData.name
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found ${roundRatings.length} round ratings for ${tournamentData.name}`);
|
logger.info(`Found ${roundRatings.length} round ratings for ${tournamentData.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error scraping tournament ${tournamentData.name}: ${error.message}`);
|
logger.error(`Error scraping tournament ${tournamentData.name}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const { db } = require('../db');
|
const { db } = require('../db');
|
||||||
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB, getAllRatingHistoriesFromDB } = require('../models/player');
|
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB, getAllRatingHistoriesFromDB } = require('../models/player');
|
||||||
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
|
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
|
||||||
const { calculatePredictedRating } = require('./rating-calculator');
|
const { calculatePredictedRating, getPreviousPDGAUpdateDate } = require('./rating-calculator');
|
||||||
const logger = require('../logger');
|
const logger = require('../logger');
|
||||||
|
|
||||||
function formatDisplayDate(dateStr) {
|
function formatDisplayDate(dateStr) {
|
||||||
@@ -42,12 +42,33 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true }
|
|||||||
let stdDev = cachedPlayer.std_dev;
|
let stdDev = cachedPlayer.std_dev;
|
||||||
let excludedRoundsCount = cachedPlayer.excluded_rounds_count;
|
let excludedRoundsCount = cachedPlayer.excluded_rounds_count;
|
||||||
let cutoffRating = cachedPlayer.cutoff_rating;
|
let cutoffRating = cachedPlayer.cutoff_rating;
|
||||||
|
let predictedCalculatedAtRaw = cachedPlayer.predicted_calculated_at;
|
||||||
if (!predictedRating || predictedRating === 0) {
|
if (!predictedRating || predictedRating === 0) {
|
||||||
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
|
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
|
||||||
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
|
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||||
stdDev = updatedPlayer?.std_dev;
|
stdDev = updatedPlayer?.std_dev;
|
||||||
excludedRoundsCount = updatedPlayer?.excluded_rounds_count;
|
excludedRoundsCount = updatedPlayer?.excluded_rounds_count;
|
||||||
cutoffRating = updatedPlayer?.cutoff_rating;
|
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 = predictedCalculatedAtRaw
|
||||||
|
? new Date(predictedCalculatedAtRaw)
|
||||||
|
: 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 rating = cachedPlayer.current_rating;
|
||||||
|
|||||||
@@ -37,39 +37,43 @@ function parseDate(dateStr) {
|
|||||||
return new Date(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() {
|
function getNextPDGAUpdateDate() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const currentMonth = today.getMonth();
|
const currentMonth = today.getMonth();
|
||||||
const currentYear = today.getFullYear();
|
const currentYear = today.getFullYear();
|
||||||
|
|
||||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1);
|
const secondTuesday = secondTuesdayOf(currentYear, currentMonth);
|
||||||
const firstTuesday = new Date(firstDayOfMonth);
|
|
||||||
|
|
||||||
const daysUntilTuesday = (2 - firstDayOfMonth.getDay() + 7) % 7;
|
|
||||||
firstTuesday.setDate(1 + daysUntilTuesday);
|
|
||||||
|
|
||||||
const secondTuesday = new Date(firstTuesday);
|
|
||||||
secondTuesday.setDate(firstTuesday.getDate() + 7);
|
|
||||||
|
|
||||||
if (today <= secondTuesday) {
|
if (today <= secondTuesday) {
|
||||||
return secondTuesday;
|
return secondTuesday;
|
||||||
} else {
|
} else {
|
||||||
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
|
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
|
||||||
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
|
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
|
||||||
|
return secondTuesdayOf(nextYear, nextMonth);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPreviousPDGAUpdateDate() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = today.getMonth();
|
||||||
|
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;
|
||||||
|
return secondTuesdayOf(prevYear, prevMonth);
|
||||||
|
}
|
||||||
|
|
||||||
function calculateStandardDeviation(ratings) {
|
function calculateStandardDeviation(ratings) {
|
||||||
if (!ratings || ratings.length === 0) return 0;
|
if (!ratings || ratings.length === 0) return 0;
|
||||||
|
|
||||||
@@ -238,4 +242,4 @@ function calculatePredictedRating(roundRatings) {
|
|||||||
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog, excludedRoundsCount, cutoffRating };
|
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog, excludedRoundsCount, cutoffRating };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };
|
module.exports = { parseDate, getNextPDGAUpdateDate, getPreviousPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ function renderSparkline(values, opts) {
|
|||||||
<span class="m-player-name"><%= player.name %></span>
|
<span class="m-player-name"><%= player.name %></span>
|
||||||
<span class="m-pdga-num">#<%= player.pdgaNumber %></span>
|
<span class="m-pdga-num">#<%= player.pdgaNumber %></span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="icon-btn refresh-icon m-refresh-icon" type="button"
|
||||||
|
onclick="event.stopPropagation(); refreshPlayerData(<%= player.pdgaNumber %>)"
|
||||||
|
title="Refresh rating + prediction" aria-label="Refresh rating and prediction">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
<span class="m-chevron">▼</span>
|
<span class="m-chevron">▼</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user