326 lines
10 KiB
JavaScript
326 lines
10 KiB
JavaScript
const { db } = require('../db');
|
|
const { parseDate } = require('../services/rating-calculator');
|
|
|
|
function getPlayerFromDB(pdgaNumber) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(
|
|
'SELECT * FROM players WHERE pdga_number = ?',
|
|
[pdgaNumber],
|
|
(err, row) => {
|
|
if (err) reject(err);
|
|
else resolve(row);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function savePlayerToDB(playerData) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(
|
|
// UPSERT (not INSERT OR REPLACE): updating in place preserves columns not
|
|
// 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],
|
|
function(err) {
|
|
if (err) return reject(err);
|
|
// 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);
|
|
});
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function getRatingHistoryFromDB(pdgaNumber) {
|
|
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(null);
|
|
|
|
db.all(
|
|
'SELECT * FROM rating_history WHERE player_id = ? ORDER BY date ASC',
|
|
[player.id],
|
|
(err, rows) => {
|
|
if (err) reject(err);
|
|
else resolve(rows);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function saveRatingHistoryToDB(pdgaNumber, ratingHistory) {
|
|
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 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();
|
|
}
|
|
}
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function getRoundHistoryFromDB(pdgaNumber) {
|
|
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 * FROM round_history WHERE player_id = ? ORDER BY date DESC',
|
|
[player.id],
|
|
(err, rows) => {
|
|
if (err) reject(err);
|
|
else resolve(rows);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function getLastRoundUpdateDate(pdgaNumber) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(
|
|
'SELECT last_round_update FROM players WHERE pdga_number = ?',
|
|
[pdgaNumber],
|
|
(err, row) => {
|
|
if (err) reject(err);
|
|
else resolve(row ? row.last_round_update : null);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function updateLastRoundUpdateDate(pdgaNumber) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(
|
|
'UPDATE players SET last_round_update = CURRENT_TIMESTAMP WHERE pdga_number = ?',
|
|
[pdgaNumber],
|
|
function(err) {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
|
|
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 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) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
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);
|
|
} else {
|
|
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (updateErr) => {
|
|
if (updateErr) reject(updateErr);
|
|
else resolve();
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
if (!isIncremental) {
|
|
db.run('DELETE FROM round_history WHERE player_id = ?', [player.id], (err) => {
|
|
if (err) return reject(err);
|
|
processRounds();
|
|
});
|
|
} else {
|
|
processRounds();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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 = ?, predicted_calculated_at = ? WHERE pdga_number = ?',
|
|
[predictedRating, stdDev, excludedRoundsCount, cutoffRating, timestamp, pdgaNumber],
|
|
function(err) {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 DESC
|
|
LIMIT ?`,
|
|
[player.id, player.id, months],
|
|
(err, rows) => {
|
|
if (err) return reject(err);
|
|
resolve(rows.map(r => r.rating).reverse());
|
|
}
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetches the full rating history for ALL players in one query.
|
|
* Returns Map<pdgaNumber, {rating, date}[]> ordered chronologically (oldest → newest).
|
|
* Mirrors getAllMonthlyHistoriesFromDB but returns every point, not monthly snapshots.
|
|
*/
|
|
function getAllRatingHistoriesFromDB() {
|
|
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
|
|
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({ date: row.date, rating: row.rating });
|
|
}
|
|
resolve(map);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function getLastRefresh() {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(
|
|
'SELECT MAX(last_updated) AS lastRefresh FROM players',
|
|
[],
|
|
(err, row) => {
|
|
if (err) reject(err);
|
|
else resolve(row ? row.lastRefresh : null);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
getPlayerFromDB,
|
|
savePlayerToDB,
|
|
getRatingHistoryFromDB,
|
|
saveRatingHistoryToDB,
|
|
getRoundHistoryFromDB,
|
|
getLastRoundUpdateDate,
|
|
updateLastRoundUpdateDate,
|
|
saveRoundHistoryToDB,
|
|
savePredictedRatingToDB,
|
|
getLastRefresh,
|
|
getMonthlyHistory,
|
|
getAllMonthlyHistoriesFromDB,
|
|
getAllRatingHistoriesFromDB
|
|
};
|