diff --git a/public/css/shared.css b/public/css/shared.css index cfc1e12..f436b20 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -551,3 +551,44 @@ a:hover { padding: 8px 6px; } } + +/* ── Delta Pills ──────────────────────────────── */ + +.delta-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + font-size: 11px; + font-weight: 500; + margin-top: 4px; +} + +.delta-pill.up { + background: var(--up-soft); + color: var(--up); +} + +.delta-pill.down { + background: var(--down-soft); + color: var(--down); +} + +.delta-pill.flat { + background: oklch(0.95 0.004 260); + color: var(--ink-3); +} + +/* ── Table Header Hints ───────────────────────── */ + +.th-hint { + display: block; + font-size: 9.5px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--ink-3); + margin-top: 2px; +} diff --git a/public/js/players.js b/public/js/players.js index d0c80e6..5741082 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -96,16 +96,10 @@ async function refreshPlayer(pdgaNumber) { if (data.success) { const row = document.getElementById(`row-${pdgaNumber}`); const ratingCell = row.querySelector('.rating'); - const ratingChangeCell = row.querySelector('.rating-change'); const nameLink = row.querySelector('.player-name a'); nameLink.textContent = data.player.name; - const ratingChangeText = data.player.ratingChange ? - (data.player.ratingChange > 0 ? `+${data.player.ratingChange}` : data.player.ratingChange.toString()) : 'N/A'; - const ratingChangeClass = data.player.ratingChange > 0 ? 'positive' : - data.player.ratingChange < 0 ? 'negative' : 'neutral'; - const ratingValue = ratingCell.querySelector('.rating-value'); if (ratingValue) { ratingValue.textContent = data.player.rating || 'N/A'; @@ -122,13 +116,13 @@ async function refreshPlayer(pdgaNumber) { } } - if (ratingChangeCell) ratingChangeCell.textContent = ratingChangeText; - if (ratingChangeCell) ratingChangeCell.className = `rating-change ${ratingChangeClass} mobile-hide`; - - const mobileChange = ratingCell.querySelector('.mobile-only.rating-change'); - if (mobileChange) { - mobileChange.textContent = ratingChangeText; - mobileChange.className = `mobile-only rating-change ${ratingChangeClass}`; + const deltaMonthPill = ratingCell.querySelector('.delta-pill'); + if (deltaMonthPill && data.player.ratingChange != null) { + const pillChange = data.player.ratingChange; + const pillText = pillChange > 0 ? `+${pillChange}` : pillChange.toString(); + const pillClass = pillChange > 0 ? 'up' : pillChange < 0 ? 'down' : 'flat'; + deltaMonthPill.textContent = pillText; + deltaMonthPill.className = `delta-pill ${pillClass}`; } } } catch (error) { diff --git a/src/models/player.js b/src/models/player.js index 758061c..8f94c8d 100644 --- a/src/models/player.js +++ b/src/models/player.js @@ -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 (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 }; diff --git a/src/services/player-service.js b/src/services/player-service.js index fd86274..523016d 100644 --- a/src/services/player-service.js +++ b/src/services/player-service.js @@ -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); } diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index 386d5bd..c73a84f 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -8,17 +8,18 @@ Player Name PDGA # Rating - Change - Predicted + Predictednext official update <% ratings.forEach(function(player, index) { - var difference = player.predictedRating && player.rating ? player.predictedRating - player.rating : 0; - var diffText = difference > 0 ? '+' + difference : difference.toString(); - var diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral'; - var ratingChangeText = player.ratingChange ? (player.ratingChange > 0 ? '+' + player.ratingChange : player.ratingChange.toString()) : 'N/A'; - var ratingChangeClass = player.ratingChange > 0 ? 'positive' : player.ratingChange < 0 ? 'negative' : 'neutral'; + var ratingChange = player.ratingChange; + var ratingChangePillText = ratingChange != null ? (ratingChange > 0 ? '+' + ratingChange : ratingChange.toString()) : null; + var ratingChangePillClass = ratingChange > 0 ? 'up' : ratingChange < 0 ? 'down' : 'flat'; + + var deltaPredicted = player.deltaPredicted ?? null; + var deltaPredictedPillText = deltaPredicted != null ? (deltaPredicted > 0 ? '+' + deltaPredicted : deltaPredicted.toString()) : null; + var deltaPredictedPillClass = deltaPredicted > 0 ? 'up' : deltaPredicted < 0 ? 'down' : 'flat'; %> <%= index + 1 %> @@ -32,21 +33,25 @@ <%- player.rating || 'Click refresh' %> -
<%= ratingChangeText %>
+ <% if (ratingChangePillText) { %> + <%= ratingChangePillText %> + <% } %>
- <%= ratingChangeText %>
<%= player.predictedRating || 'N/A' %>
+ <% if (deltaPredictedPillText) { %> + <%= deltaPredictedPillText %> + <% } %>
- +
Rating History for <%= player.name %> @@ -61,4 +66,4 @@ <% }); %> -<% } %> \ No newline at end of file +<% } %>