feat: add monthlyHistory[] per player via getMonthlyHistory + bulk fetch (#6)
Add getMonthlyHistory() to models/player for single-player use and getAllMonthlyHistoriesFromDB() for bulk fetches (one query, grouped in memory). Wire monthlyHistory into all player objects returned by getPlayerDataFromDB and getAllRatingsFromDB. Bulk path pre-fetches in one query to avoid N extra per-player queries.
This commit is contained in:
+84
-14
@@ -33,7 +33,7 @@ function getRatingHistoryFromDB(pdgaNumber) {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return resolve(null);
|
||||
|
||||
|
||||
db.all(
|
||||
'SELECT * FROM rating_history WHERE player_id = ? ORDER BY date ASC',
|
||||
[player.id],
|
||||
@@ -51,26 +51,26 @@ function saveRatingHistoryToDB(pdgaNumber, ratingHistory) {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return reject(new Error('Player not found'));
|
||||
|
||||
|
||||
db.run('DELETE FROM rating_history WHERE player_id = ?', [player.id], (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
|
||||
if (ratingHistory.length === 0) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
|
||||
let completed = 0;
|
||||
const total = ratingHistory.length;
|
||||
|
||||
|
||||
ratingHistory.forEach(entry => {
|
||||
const parsedDate = parseDate(entry.date);
|
||||
|
||||
|
||||
db.run(
|
||||
'INSERT INTO rating_history (player_id, date, rating) VALUES (?, ?, ?)',
|
||||
[player.id, parsedDate.toISOString().split('T')[0], entry.rating],
|
||||
(err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
|
||||
completed++;
|
||||
if (completed === total) {
|
||||
resolve();
|
||||
@@ -88,7 +88,7 @@ function getRoundHistoryFromDB(pdgaNumber) {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return resolve([]);
|
||||
|
||||
|
||||
db.all(
|
||||
'SELECT * FROM round_history WHERE player_id = ? ORDER BY date DESC',
|
||||
[player.id],
|
||||
@@ -132,7 +132,7 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return reject(new Error('Player not found'));
|
||||
|
||||
|
||||
const processRounds = () => {
|
||||
if (roundData.length === 0) {
|
||||
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (err) => {
|
||||
@@ -141,13 +141,13 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO round_history (player_id, date, competition_name, rating) VALUES (?, ?, ?, ?)');
|
||||
|
||||
|
||||
for (const round of roundData) {
|
||||
stmt.run([player.id, round.date.toISOString().split('T')[0], round.competition || 'Unknown', round.rating]);
|
||||
}
|
||||
|
||||
|
||||
stmt.finalize((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
@@ -159,7 +159,7 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
if (!isIncremental) {
|
||||
db.run('DELETE FROM round_history WHERE player_id = ?', [player.id], (err) => {
|
||||
if (err) return reject(err);
|
||||
@@ -185,6 +185,74 @@ function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns monthly rating snapshots for one player (latest entry per calendar month),
|
||||
* ordered oldest → newest. At most `months` entries; [] if none.
|
||||
*/
|
||||
function getMonthlyHistory(pdgaNumber, months = 12) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return resolve([]);
|
||||
|
||||
db.all(
|
||||
`SELECT rating
|
||||
FROM rating_history
|
||||
WHERE player_id = ?
|
||||
AND date IN (
|
||||
SELECT MAX(date)
|
||||
FROM rating_history
|
||||
WHERE player_id = ?
|
||||
GROUP BY strftime('%Y-%m', date)
|
||||
)
|
||||
ORDER BY date ASC
|
||||
LIMIT ?`,
|
||||
[player.id, player.id, months],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows.map(r => r.rating));
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the last `months` monthly rating snapshots for ALL players in one query.
|
||||
* Returns a Map<pdgaNumber, number[]> (oldest → newest per player).
|
||||
* Use this in bulk-fetch paths to avoid N+1 queries.
|
||||
*/
|
||||
function getAllMonthlyHistoriesFromDB(months = 12) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT p.pdga_number, rh.date, rh.rating
|
||||
FROM rating_history rh
|
||||
JOIN players p ON rh.player_id = p.id
|
||||
INNER JOIN (
|
||||
SELECT player_id, MAX(date) AS max_date
|
||||
FROM rating_history
|
||||
GROUP BY player_id, strftime('%Y-%m', date)
|
||||
) latest ON rh.player_id = latest.player_id AND rh.date = latest.max_date
|
||||
ORDER BY p.pdga_number, rh.date ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.pdga_number)) map.set(row.pdga_number, []);
|
||||
map.get(row.pdga_number).push(row.rating);
|
||||
}
|
||||
// Trim each player's history to the requested window
|
||||
for (const [key, arr] of map) {
|
||||
if (arr.length > months) map.set(key, arr.slice(-months));
|
||||
}
|
||||
resolve(map);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getLastRefresh() {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
@@ -208,5 +276,7 @@ module.exports = {
|
||||
updateLastRoundUpdateDate,
|
||||
saveRoundHistoryToDB,
|
||||
savePredictedRatingToDB,
|
||||
getLastRefresh
|
||||
getLastRefresh,
|
||||
getMonthlyHistory,
|
||||
getAllMonthlyHistoriesFromDB
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const { db } = require('../db');
|
||||
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB } = require('../models/player');
|
||||
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB } = require('../models/player');
|
||||
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
|
||||
const { calculatePredictedRating } = require('./rating-calculator');
|
||||
const logger = require('../logger');
|
||||
|
||||
async function getPlayerDataFromDB(pdgaNumber) {
|
||||
async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } = {}) {
|
||||
try {
|
||||
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||
if (cachedPlayer) {
|
||||
@@ -23,6 +23,11 @@ async function getPlayerDataFromDB(pdgaNumber) {
|
||||
const resolvedPredicted = predictedRating > 0 ? predictedRating : null;
|
||||
const resolvedStdDev = stdDev > 0 ? stdDev : null;
|
||||
|
||||
// Skip in bulk-fetch paths where caller supplies history via getAllMonthlyHistoriesFromDB
|
||||
const monthlyHistory = includeMonthlyHistory
|
||||
? await getMonthlyHistory(cachedPlayer.pdga_number)
|
||||
: [];
|
||||
|
||||
return {
|
||||
pdgaNumber: cachedPlayer.pdga_number,
|
||||
name: cachedPlayer.name,
|
||||
@@ -34,7 +39,7 @@ async function getPlayerDataFromDB(pdgaNumber) {
|
||||
lastMonthRating: (rating != null && ratingChange != null) ? rating - ratingChange : null,
|
||||
// gap between next predicted update and current rating (null when either is missing)
|
||||
deltaPredicted: (resolvedPredicted != null && rating != null) ? resolvedPredicted - rating : null,
|
||||
monthlyHistory: []
|
||||
monthlyHistory
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -137,6 +142,9 @@ async function getAllRatingsFromDB(progressCallback = null) {
|
||||
|
||||
logger.info(`Loading ${allPlayers.length} players from database...`);
|
||||
|
||||
// Fetch all monthly histories in one query so the per-player loop doesn't add N extra queries
|
||||
const monthlyHistoryMap = await getAllMonthlyHistoriesFromDB(12);
|
||||
|
||||
const ratings = [];
|
||||
const total = allPlayers.length;
|
||||
|
||||
@@ -154,9 +162,10 @@ async function getAllRatingsFromDB(progressCallback = null) {
|
||||
}
|
||||
|
||||
try {
|
||||
const playerData = await getPlayerDataFromDB(pdgaNumber);
|
||||
const playerData = await getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory: false });
|
||||
|
||||
if (playerData) {
|
||||
playerData.monthlyHistory = monthlyHistoryMap.get(pdgaNumber) ?? [];
|
||||
ratings.push(playerData);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user