Compare commits

...

13 Commits

Author SHA1 Message Date
shcizo 6f3e33a5ea Merge pull request 'fix: invalidate stale predicted_rating after PDGA cycle rollover (#29)' (#31) from fix/invalidate-stale-predicted-rating-29 into main
Release / release (push) Failing after 6s
2026-06-09 14:02:42 +02:00
Samuel Enocsson c7fb4a7068 fix: use re-fetched timestamp after recompute + rename helper var (#29)
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.
2026-06-09 11:04:53 +02:00
Samuel Enocsson 27ffa096e4 fix: invalidate stale predicted_rating after PDGA cycle rollover (#29) 2026-06-09 10:58:42 +02:00
shcizo 5198a1c0f4 Merge pull request 'fix: preserve predicted_rating via UPSERT in savePlayerToDB (#11)' (#28) from fix/upsert-preserve-predicted-rating-11 into main
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 10s
Build and deploy / deploy (push) Successful in 4s
2026-06-08 09:53:01 +02:00
Samuel Enocsson 7297c0a16b fix: preserve predicted_rating via UPSERT in savePlayerToDB (#11)
INSERT OR REPLACE deletes the existing row and resets columns absent from
the VALUES list (predicted_rating, std_dev, last_round_update,
excluded_rounds_count, cutoff_rating) to NULL. Refresh-all called this for
every player, wiping predicted ratings table-wide.

Switch to a SQLite UPSERT keyed on pdga_number that updates only the four
scraped columns in place, leaving the predicted-rating columns and the
24h-cooldown timestamp untouched. Mirror course.js's lastID==0 SELECT
fallback so the function still returns a real player id on the update path.
2026-06-08 09:50:03 +02:00
Release Bot ada2dcb4ae 1.4.2
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 33s
Build and deploy / deploy (push) Successful in 8s
2026-06-08 06:46:23 +00:00
shcizo 5ece854340 Merge pull request 'feat: add refresh button to mobile player card (#26)' (#27) from feat/mobile-card-refresh-button-26 into main
Release / release (push) Successful in 8s
2026-06-08 08:46:13 +02:00
Samuel Enocsson 2ef7de4e58 fix: spin only the icon glyph in mobile refresh button (#26) 2026-06-08 08:44:51 +02:00
Samuel Enocsson 16c045e7cc feat: add refresh button to mobile player card (#26) 2026-06-08 08:24:09 +02:00
Release Bot 8ee5cc3861 1.4.1
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 23s
Build and deploy / deploy (push) Successful in 8s
2026-06-01 07:04:42 +00:00
shcizo 2561ee12ef Merge pull request 'fix: parse latest tournament from recent-events list on player page (#24)' (#25) from fix/parse-recent-events-tournament-24 into main
Release / release (push) Successful in 25s
2026-06-01 09:04:13 +02:00
Samuel Enocsson 0d2f0fa3a8 fix: skip recent-events tournament when extracted date predates afterDate (#24) 2026-06-01 08:57:51 +02:00
Samuel Enocsson ec3ae872da fix: parse latest tournament from recent-events list on player page (#24) 2026-06-01 08:53:12 +02:00
10 changed files with 231 additions and 64 deletions
+2 -2
View File
@@ -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
View File
@@ -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": {
+32
View File
@@ -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
View File
@@ -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'));
} }
} }
+10
View File
@@ -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
View File
@@ -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();
+102 -29
View File
@@ -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}`);
} }
+22 -1
View File
@@ -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;
+24 -20
View File
@@ -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 };
+5
View File
@@ -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">&#9660;</span> <span class="m-chevron">&#9660;</span>
</div> </div>