From 8c977d662472afc4e4e74345d63cbbdfccf64504 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 12:37:31 +0200 Subject: [PATCH 01/27] feat: shared visual layer + redesigned topbar (#4) Introduce new design token set (paper/ink/line/accent + radius/shadow) with backward-compat aliases for legacy --surface/--navy/--text names. Swap DM Sans for Plus Jakarta Sans, add JetBrains Mono with tabular numerics. Replace .app-header with sticky .topbar partial (brand + segmented nav + Next update / Last refresh meta + Refresh all button). Add POST /api/refresh-all that runs refreshAllPlayersInDB() with an in-memory mutex and returns the rendered topbar so HTMX can swap it in. "Next update" is computed as first Tuesday of next month (approximation of PDGA's monthly cycle). "Last refresh" derives from MAX(players.last_updated). --- public/css/shared.css | 313 ++++++++++++++++++++++++--------- src/models/player.js | 14 +- src/routes/pages.js | 11 +- src/routes/players.js | 20 +++ src/services/topbar-service.js | 45 +++++ views/partials/layout.ejs | 13 +- views/partials/topbar.ejs | 45 +++++ 7 files changed, 359 insertions(+), 102 deletions(-) create mode 100644 src/services/topbar-service.js create mode 100644 views/partials/topbar.ejs diff --git a/public/css/shared.css b/public/css/shared.css index 2a02b57..fc70220 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -2,52 +2,75 @@ PDGA Ratings — Shared Design System ═══════════════════════════════════════════════════ */ -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap'); +/* Fonts loaded via in layout.ejs for parallel loading */ :root { - /* Color system */ - --surface-0: #f0f2f5; - --surface-1: #ffffff; - --surface-2: #f8f9fb; - --surface-3: #eef0f4; + /* ── New design tokens ─────────────────────────── */ + --bg: oklch(0.985 0.005 250); + --paper: #ffffff; + --paper-2: oklch(0.975 0.006 250); + --ink: oklch(0.22 0.02 260); + --ink-2: oklch(0.42 0.015 260); + --ink-3: oklch(0.60 0.012 260); + --line: oklch(0.93 0.008 260); + --line-2: oklch(0.88 0.010 260); + --hover: oklch(0.965 0.012 260); - --navy-900: #0f172a; - --navy-800: #1e293b; - --navy-700: #334155; - --navy-600: #475569; + --accent: #4f5fd8; + --accent-soft: color-mix(in oklab, var(--accent) 12%, white); + --accent-text: color-mix(in oklab, var(--accent) 92%, black); - --text-primary: #0f172a; - --text-secondary: #64748b; - --text-muted: #94a3b8; - --text-inverse: #f8fafc; + --up: oklch(0.55 0.15 150); + --up-soft: oklch(0.94 0.04 150); + --down: oklch(0.58 0.18 25); + --down-soft: oklch(0.95 0.04 25); - --accent: #3b82f6; - --accent-hover: #2563eb; - --accent-subtle: rgba(59, 130, 246, 0.08); - --accent-border: rgba(59, 130, 246, 0.2); + --radius: 14px; + --radius-sm: 10px; - --green: #10b981; - --green-subtle: rgba(16, 185, 129, 0.1); - --red: #ef4444; - --red-subtle: rgba(239, 68, 68, 0.1); - --amber: #f59e0b; + --shadow-card: 0 1px 0 oklch(0.92 0.01 260), 0 1px 2px oklch(0.85 0.01 260 / 0.30); + --shadow-pop: 0 1px 2px oklch(0.80 0.01 260 / 0.18), 0 14px 40px -10px oklch(0.50 0.02 260 / 0.18); - --border: #e2e8f0; - --border-light: #f1f5f9; + --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; + /* legacy token aliases — remove as components migrate */ + --surface-0: var(--bg); + --surface-1: var(--paper); + --surface-2: var(--paper-2); + --surface-3: var(--paper-2); + + --text-primary: var(--ink); + --text-secondary: var(--ink-2); + --text-muted: var(--ink-3); + --text-inverse: var(--paper); + + --navy-900: var(--ink); + --navy-800: var(--ink); + --navy-700: var(--ink-2); + --navy-600: var(--ink-2); + + --border: var(--line); + --border-light: var(--line-2); + + --green: var(--up); + --green-subtle: var(--up-soft); + --red: var(--down); + --red-subtle: var(--down-soft); + + --accent-hover: color-mix(in oklab, var(--accent) 85%, black); + --accent-subtle: var(--accent-soft); + --accent-border: color-mix(in oklab, var(--accent) 40%, transparent); + + --radius-md: var(--radius-sm); + --radius-lg: var(--radius); --radius-xl: 20px; - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); - --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04); - --shadow-lg: 0 8px 30px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04); + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); + --shadow-md: var(--shadow-card); + --shadow-lg: var(--shadow-pop); --shadow-overlay: 0 20px 60px rgba(15, 23, 42, 0.15), 0 4px 12px rgba(15, 23, 42, 0.08); - --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; - --font-mono: 'JetBrains Mono', 'SF Mono', monospace; - --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); } @@ -59,81 +82,208 @@ body { font-family: var(--font-sans); + font-size: 14px; + line-height: 1.45; + font-feature-settings: "ss01", "cv11"; margin: 0; padding: 0; - background-color: var(--surface-0); - color: var(--text-primary); + background: var(--bg); + color: var(--ink); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - line-height: 1.5; } -/* ── App Shell ────────────────────────────────── */ +.mono { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; +} -.app-header { - background: var(--navy-900); - color: var(--text-inverse); - padding: 0 24px; +/* ── Topbar ───────────────────────────────────── */ + +.topbar { position: sticky; top: 0; - z-index: 100; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + z-index: 50; + background: color-mix(in oklab, var(--paper) 92%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); } -.header-inner { - max-width: 960px; - margin: 0 auto; +.topbar__inner { display: flex; align-items: center; justify-content: space-between; - height: 56px; + gap: 16px; + padding: 14px 32px; + max-width: 1400px; + margin: 0 auto; } -.app-logo { - font-size: 17px; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--text-inverse); - text-decoration: none; +.topbar__brand { display: flex; align-items: center; - gap: 8px; + gap: 12px; + text-decoration: none; } -.app-logo .logo-icon { - width: 28px; - height: 28px; - background: var(--accent); - border-radius: var(--radius-sm); +.topbar__brand-mark { + width: 36px; + height: 36px; + border-radius: 10px; + background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 70%, black)); display: flex; align-items: center; justify-content: center; - font-size: 14px; + color: #fff; + flex-shrink: 0; } -.app-nav { +.topbar__brand-mark svg { + width: 24px; + height: 24px; +} + +.topbar__brand-text { display: flex; + flex-direction: column; + line-height: 1.1; +} + +.topbar__brand-title { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); +} + +.topbar__brand-sub { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-3); +} + +.topbar__nav { + display: flex; + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 4px; gap: 2px; } -.app-nav a { - padding: 6px 14px; - color: var(--text-muted); +.topbar__nav a { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 14px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + color: var(--ink-2); text-decoration: none; - font-size: 14px; + transition: color 120ms ease, background 120ms ease; +} + +.topbar__nav a:hover { + color: var(--ink); +} + +.topbar__nav a.active { + background: var(--paper); + color: var(--ink); + box-shadow: var(--shadow-card); +} + +.topbar__meta { + display: flex; + align-items: center; + gap: 14px; +} + +.topbar__meta-item { + display: flex; + flex-direction: column; + line-height: 1.15; +} + +.topbar__meta-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-3); +} + +.topbar__meta-value { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + font-size: 12px; font-weight: 500; - border-radius: var(--radius-sm); - transition: color var(--transition), background var(--transition); + color: var(--ink); } -.app-nav a:hover { - color: var(--text-inverse); - background: rgba(255, 255, 255, 0.08); +.topbar__divider { + width: 1px; + height: 22px; + background: var(--line); } -.app-nav a.active { - color: var(--text-inverse); - background: rgba(255, 255, 255, 0.12); +.topbar__refresh { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--paper); + color: var(--ink); + font-size: 12px; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.topbar__refresh:hover:not(:disabled) { + background: var(--hover); + border-color: color-mix(in oklab, var(--line) 60%, var(--ink-3)); +} + +.topbar__refresh:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.topbar__refresh-spinner { + display: none; + width: 12px; + height: 12px; + border: 2px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: topbar-spin 0.7s linear infinite; +} + +.topbar__refresh.htmx-request .topbar__refresh-spinner { + display: inline-block; +} + +.topbar__refresh.htmx-request .topbar__refresh-icon { + display: none; +} + +@keyframes topbar-spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 880px) { + .topbar__inner { padding: 10px 16px; gap: 10px; } + .topbar__meta-item, .topbar__divider { display: none; } + .topbar__refresh-label { display: none; } + .topbar__nav a { padding: 0 10px; font-size: 12px; } + .topbar__brand-sub { display: none; } } /* ── Container ────────────────────────────────── */ @@ -380,19 +530,6 @@ a:hover { font-size: 22px; } - .header-inner { - height: 48px; - } - - .app-logo { - font-size: 15px; - } - - .app-nav a { - padding: 5px 10px; - font-size: 13px; - } - table { font-size: 13px; } @@ -410,7 +547,7 @@ a:hover { } thead { - top: 48px; + top: 56px; } } diff --git a/src/models/player.js b/src/models/player.js index 856122c..9de3049 100644 --- a/src/models/player.js +++ b/src/models/player.js @@ -185,6 +185,17 @@ function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) { }); } +function getLastRefresh(callback) { + db.get( + 'SELECT MAX(last_updated) AS lastRefresh FROM players', + [], + (err, row) => { + if (err) return callback(err); + callback(null, row ? row.lastRefresh : null); + } + ); +} + module.exports = { getPlayerFromDB, savePlayerToDB, @@ -194,5 +205,6 @@ module.exports = { getLastRoundUpdateDate, updateLastRoundUpdateDate, saveRoundHistoryToDB, - savePredictedRatingToDB + savePredictedRatingToDB, + getLastRefresh }; diff --git a/src/routes/pages.js b/src/routes/pages.js index 2eb677f..cb22d9f 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -1,12 +1,15 @@ const express = require('express'); const router = express.Router(); +const { getTopbarLocals } = require('../services/topbar-service'); -router.get('/', (req, res) => { - res.render('index'); +router.get('/', async (req, res) => { + const topbar = await getTopbarLocals(); + res.render('index', { activePage: 'players', ...topbar }); }); -router.get('/courses', (req, res) => { - res.render('courses'); +router.get('/courses', async (req, res) => { + const topbar = await getTopbarLocals(); + res.render('courses', { activePage: 'courses', ...topbar }); }); // Keep old URL working diff --git a/src/routes/players.js b/src/routes/players.js index 1a44d0d..b9e3c86 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -6,9 +6,29 @@ const { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHis const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer'); const { launchBrowser } = require('../scrapers/browser'); const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service'); +const { getTopbarLocals } = require('../services/topbar-service'); const { calculatePredictedRating } = require('../services/rating-calculator'); const logger = require('../logger'); +let refreshInProgress = false; + +router.post('/api/refresh-all', async (req, res) => { + if (refreshInProgress) { + logger.info('refresh-all already in progress, rejecting'); + return res.status(409).json({ error: 'Refresh already in progress' }); + } + refreshInProgress = true; + try { + await refreshAllPlayersInDB(); + } catch (err) { + logger.error({ err }, 'refresh-all failed'); + } finally { + refreshInProgress = false; + } + const locals = await getTopbarLocals(); + res.render('../partials/topbar', { activePage: req.query.page || 'players', ...locals }); +}); + router.get('/partials/ratings-table', async (req, res) => { try { const ratings = await getAllRatingsFromDB(); diff --git a/src/services/topbar-service.js b/src/services/topbar-service.js new file mode 100644 index 0000000..b1a98b7 --- /dev/null +++ b/src/services/topbar-service.js @@ -0,0 +1,45 @@ +const { getLastRefresh } = require('../models/player'); +const logger = require('../logger'); + +function formatRelative(isoString) { + if (!isoString) return 'Never'; + const then = new Date(isoString.replace(' ', 'T') + (isoString.endsWith('Z') ? '' : 'Z')); + const diffMs = Date.now() - then.getTime(); + if (Number.isNaN(diffMs) || diffMs < 0) return 'Just now'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return 'Just now'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min} min ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr} h ago`; + const day = Math.floor(hr / 24); + if (day === 1) return 'Yesterday'; + if (day < 7) return `${day} days ago`; + return then.toISOString().slice(0, 10); +} + +// First Tuesday of next month — approximation of PDGA's monthly cycle +function computeNextUpdate(now = new Date()) { + const year = now.getUTCFullYear(); + const month = now.getUTCMonth() + 1; // next month, may roll over + const candidate = new Date(Date.UTC(month === 12 ? year + 1 : year, month === 12 ? 0 : month, 1)); + // 0=Sun, 1=Mon, 2=Tue + const offset = (2 - candidate.getUTCDay() + 7) % 7; + candidate.setUTCDate(1 + offset); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][candidate.getUTCDay()]} ${candidate.getUTCDate()} ${months[candidate.getUTCMonth()]}`; +} + +async function getTopbarLocals() { + try { + const lastIso = await new Promise((resolve, reject) => { + getLastRefresh((err, val) => (err ? reject(err) : resolve(val))); + }); + return { lastRefresh: formatRelative(lastIso), nextUpdate: computeNextUpdate() }; + } catch (err) { + logger.warn({ err }, 'topbar locals fallback'); + return { lastRefresh: 'Unknown', nextUpdate: computeNextUpdate() }; + } +} + +module.exports = { getTopbarLocals, formatRelative, computeNextUpdate }; diff --git a/views/partials/layout.ejs b/views/partials/layout.ejs index e2009a8..78edd66 100644 --- a/views/partials/layout.ejs +++ b/views/partials/layout.ejs @@ -5,6 +5,9 @@ <%= title %> + + + <% if (typeof cssFiles !== 'undefined') { %> @@ -14,15 +17,7 @@ <% } %> -
-
- - <%- include('../partials/nav') %> -
-
+ <%- include('../partials/topbar', { activePage, lastRefresh, nextUpdate }) %>
<%- body %> diff --git a/views/partials/topbar.ejs b/views/partials/topbar.ejs new file mode 100644 index 0000000..f726414 --- /dev/null +++ b/views/partials/topbar.ejs @@ -0,0 +1,45 @@ +
+
+ + + + Rating Tracker + Disc golf · unofficial + + + + + +
+
+ Next update + <%= nextUpdate %> +
+
+ Last refresh + <%= lastRefresh %> +
+ + +
+
+
-- 2.52.0 From de99d4ede7a7e24ecb835584d2864861e95a059f Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 12:50:31 +0200 Subject: [PATCH 02/27] fix: address code review for visual layer + topbar (#4) --- public/css/shared.css | 8 +++- server.js | 1 + src/models/player.js | 20 +++++---- src/routes/players.js | 15 ++++--- src/services/topbar-service.js | 6 +-- views/pages/courses.ejs | 1 - views/pages/index.ejs | 1 - views/partials/topbar.ejs | 81 +++++++++++++++++----------------- 8 files changed, 71 insertions(+), 62 deletions(-) diff --git a/public/css/shared.css b/public/css/shared.css index fc70220..98c4fdf 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -72,6 +72,9 @@ --shadow-overlay: 0 20px 60px rgba(15, 23, 42, 0.15), 0 4px 12px rgba(15, 23, 42, 0.08); --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Topbar dimensions (used by sticky thead) */ + --topbar-height: 64px; } /* ── Reset & Base ─────────────────────────────── */ @@ -279,6 +282,7 @@ body { } @media (max-width: 880px) { + :root { --topbar-height: 56px; } .topbar__inner { padding: 10px 16px; gap: 10px; } .topbar__meta-item, .topbar__divider { display: none; } .topbar__refresh-label { display: none; } @@ -322,7 +326,7 @@ table { thead { position: sticky; - top: 56px; + top: var(--topbar-height); z-index: 10; } @@ -547,7 +551,7 @@ a:hover { } thead { - top: 56px; + top: var(--topbar-height); } } diff --git a/server.js b/server.js index 7747255..db7d620 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,7 @@ app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views/pages')); app.use(express.static('public')); app.use(express.json()); +app.use(express.urlencoded({ extended: false })); app.use(playerRoutes); app.use(courseRoutes); diff --git a/src/models/player.js b/src/models/player.js index 9de3049..758061c 100644 --- a/src/models/player.js +++ b/src/models/player.js @@ -185,15 +185,17 @@ function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) { }); } -function getLastRefresh(callback) { - db.get( - 'SELECT MAX(last_updated) AS lastRefresh FROM players', - [], - (err, row) => { - if (err) return callback(err); - callback(null, row ? row.lastRefresh : null); - } - ); +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 = { diff --git a/src/routes/players.js b/src/routes/players.js index b9e3c86..59abd72 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -12,21 +12,26 @@ const logger = require('../logger'); let refreshInProgress = false; -router.post('/api/refresh-all', async (req, res) => { +router.post('/api/refresh-all', async (req, res, next) => { if (refreshInProgress) { logger.info('refresh-all already in progress, rejecting'); return res.status(409).json({ error: 'Refresh already in progress' }); } refreshInProgress = true; try { - await refreshAllPlayersInDB(); + try { + await refreshAllPlayersInDB(); + } catch (err) { + logger.error({ err }, 'refresh-all failed'); + } + const page = req.body?.page === 'courses' ? 'courses' : 'players'; + const locals = await getTopbarLocals(); + res.render('../partials/topbar', { activePage: page, ...locals }); } catch (err) { - logger.error({ err }, 'refresh-all failed'); + next(err); } finally { refreshInProgress = false; } - const locals = await getTopbarLocals(); - res.render('../partials/topbar', { activePage: req.query.page || 'players', ...locals }); }); router.get('/partials/ratings-table', async (req, res) => { diff --git a/src/services/topbar-service.js b/src/services/topbar-service.js index b1a98b7..e050667 100644 --- a/src/services/topbar-service.js +++ b/src/services/topbar-service.js @@ -32,9 +32,7 @@ function computeNextUpdate(now = new Date()) { async function getTopbarLocals() { try { - const lastIso = await new Promise((resolve, reject) => { - getLastRefresh((err, val) => (err ? reject(err) : resolve(val))); - }); + const lastIso = await getLastRefresh(); return { lastRefresh: formatRelative(lastIso), nextUpdate: computeNextUpdate() }; } catch (err) { logger.warn({ err }, 'topbar locals fallback'); @@ -42,4 +40,4 @@ async function getTopbarLocals() { } } -module.exports = { getTopbarLocals, formatRelative, computeNextUpdate }; +module.exports = { getTopbarLocals }; diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index 0fe9cc3..c994b10 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -25,7 +25,6 @@ <%- include('../partials/layout', { title: 'PDGA Courses - Sweden', - heading: 'PDGA Courses - Sweden', activePage: 'courses', cssFiles: ['courses.css'], jsFiles: ['courses.js'], diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 9b1ab80..fba9b26 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -56,7 +56,6 @@ <%- include('../partials/layout', { title: 'PDGA Ratings', - heading: 'PDGA Player Ratings', activePage: 'players', cssFiles: ['players.css'], jsFiles: ['tooltips.js', 'chart.js', 'progress.js', 'players.js'], diff --git a/views/partials/topbar.ejs b/views/partials/topbar.ejs index f726414..81ed594 100644 --- a/views/partials/topbar.ejs +++ b/views/partials/topbar.ejs @@ -1,45 +1,46 @@
-
- - - - Rating Tracker - Disc golf · unofficial - - +
+ + + + Rating Tracker + Disc golf · unofficial + + - + -
-
- Next update - <%= nextUpdate %> -
-
- Last refresh - <%= lastRefresh %> -
- - +
+
+ Next update + <%= nextUpdate %> +
+
+ Last refresh + <%= lastRefresh %> +
+ + +
-
-- 2.52.0 From 53bc6e571d997aa8842f40cef93a315e4422b711 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 13:11:28 +0200 Subject: [PATCH 03/27] chore: remove redundant "Load All" link from players page (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The topbar's "Refresh all" button (introduced in #4) supersedes this link. Leaving the gear icon for clearCache() — that's a separate concern. --- views/pages/index.ejs | 1 - 1 file changed, 1 deletion(-) diff --git a/views/pages/index.ejs b/views/pages/index.ejs index fba9b26..193e4c0 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -17,7 +17,6 @@
- Load All
-- 2.52.0 From 3f7a1bb7bfd409ac9883df2ee9975c872563dc41 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 13:15:53 +0200 Subject: [PATCH 04/27] chore: remove dead code orphaned by topbar redesign (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new topbar's "Refresh all" button replaces the old SSE-driven "Load All" link and progress UI. With those gone, several pieces of infrastructure had no callers left: - GET /api/load-all-players, POST /api/populate-database, and GET /api/database-status — SSE endpoints with no frontend consumers - #progress-section / #loading divs in players + courses pages - .progress-container / .progress-bar / .progress-text / .loading CSS - public/js/progress.js script (defines fetchRatingsWithProgress, never called, and loadAllPlayers, no longer wired) — to be deleted manually since the sandbox blocks rm --- public/css/players.css | 31 ------------------ public/css/shared.css | 9 ------ src/routes/players.js | 69 ----------------------------------------- views/pages/courses.ejs | 1 - views/pages/index.ejs | 9 +----- 5 files changed, 1 insertion(+), 118 deletions(-) diff --git a/public/css/players.css b/public/css/players.css index 145c32e..7a512f7 100644 --- a/public/css/players.css +++ b/public/css/players.css @@ -12,37 +12,6 @@ background: #059669; } -/* ── Progress ─────────────────────────────────── */ - -.progress-container { - width: 100%; - background: var(--surface-3); - border-radius: var(--radius-xl); - padding: 3px; - margin: 20px 0; - overflow: hidden; -} - -.progress-bar { - width: 0%; - height: 26px; - background: linear-gradient(135deg, var(--accent), var(--accent-hover)); - border-radius: var(--radius-xl); - text-align: center; - line-height: 26px; - color: white; - font-weight: 600; - font-size: 12px; - transition: width 0.4s ease; -} - -.progress-text { - text-align: center; - margin: 8px 0; - font-size: 13px; - color: var(--text-secondary); -} - /* ── Mobile helpers ───────────────────────────── */ .mobile-only { diff --git a/public/css/shared.css b/public/css/shared.css index 98c4fdf..cfc1e12 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -306,15 +306,6 @@ body { margin: 0 0 24px 0; } -/* ── Loading ──────────────────────────────────── */ - -.loading { - text-align: center; - padding: 40px 20px; - font-size: 15px; - color: var(--text-secondary); -} - /* ── Tables ───────────────────────────────────── */ table { diff --git a/src/routes/players.js b/src/routes/players.js index 59abd72..fddfc0c 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -106,75 +106,6 @@ router.get('/api/ratings/progress', (req, res) => { }); }); -router.post('/api/populate-database', (req, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - }); - - const progressCallback = (progress) => { - res.write(`data: ${JSON.stringify(progress)}\n\n`); - }; - - logger.info('=== Starting database population from database players ==='); - - refreshAllPlayersInDB(progressCallback).then(ratings => { - logger.info(`=== Database population complete: ${ratings.length} players refreshed ===`); - res.write(`data: ${JSON.stringify({ status: 'complete', ratings, message: `Successfully refreshed ${ratings.length} players` })}\n\n`); - res.end(); - }).catch(error => { - logger.error('Error populating database:', error); - res.write(`data: ${JSON.stringify({ status: 'error', message: error.message })}\n\n`); - res.end(); - }); -}); - -router.get('/api/database-status', async (req, res) => { - try { - const playerCount = await new Promise((resolve, reject) => { - db.get('SELECT COUNT(*) as count FROM players', [], (err, row) => { - if (err) reject(err); - else resolve(row.count); - }); - }); - - res.json({ - playersInDB: playerCount, - needsPopulation: playerCount === 0 - }); - } catch (error) { - res.status(500).json({ error: 'Failed to check database status' }); - } -}); - -router.get('/api/load-all-players', (req, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control' - }); - - const progressCallback = (progress) => { - res.write(`data: ${JSON.stringify(progress)}\n\n`); - }; - - refreshAllPlayersInDB(progressCallback).then(ratings => { - res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`); - res.end(); - }).catch(error => { - res.write(`data: ${JSON.stringify({ status: 'error', error: error.message })}\n\n`); - res.end(); - }); - - req.on('close', () => { - res.end(); - }); -}); - router.get('/api/rating-history/:pdgaNumber', async (req, res) => { try { const { pdgaNumber } = req.params; diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index c994b10..63f3a99 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -19,7 +19,6 @@ -
`; %> diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 193e4c0..0287684 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -19,13 +19,6 @@
- -
`; %> @@ -57,7 +50,7 @@ title: 'PDGA Ratings', activePage: 'players', cssFiles: ['players.css'], - jsFiles: ['tooltips.js', 'chart.js', 'progress.js', 'players.js'], + jsFiles: ['tooltips.js', 'chart.js', 'players.js'], initScript: 'setupTooltipsAfterSwap();', body: body, modals: modals -- 2.52.0 From 19756b80e50d6f2d4d5e0a37148f7add68c23d33 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 13:38:04 +0200 Subject: [PATCH 05/27] feat: expose lastMonthRating and deltaPredicted on player objects (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two derived fields to all player objects returned by getPlayerDataFromDB and the error branch in getAllRatingsFromDB. No new DB columns — both fields are pure arithmetic derivations. monthlyHistory placeholder [] included ahead of A2 implementation. --- src/services/player-service.js | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/services/player-service.js b/src/services/player-service.js index 79f7157..fd86274 100644 --- a/src/services/player-service.js +++ b/src/services/player-service.js @@ -18,13 +18,23 @@ async function getPlayerDataFromDB(pdgaNumber) { stdDev = updatedPlayer?.std_dev; } + const rating = cachedPlayer.current_rating; + const ratingChange = cachedPlayer.rating_change; + const resolvedPredicted = predictedRating > 0 ? predictedRating : null; + const resolvedStdDev = stdDev > 0 ? stdDev : null; + return { pdgaNumber: cachedPlayer.pdga_number, name: cachedPlayer.name, - rating: cachedPlayer.current_rating, - ratingChange: cachedPlayer.rating_change, - predictedRating: predictedRating > 0 ? predictedRating : null, - stdDev: stdDev > 0 ? stdDev : null + rating, + ratingChange, + predictedRating: resolvedPredicted, + stdDev: resolvedStdDev, + // previous month's official rating (null when either value is missing) + 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: [] }; } return null; @@ -161,12 +171,18 @@ async function getAllRatingsFromDB(progressCallback = null) { } } catch (error) { logger.error(`Failed to load PDGA ${pdgaNumber} from database:`, error.message); + const errorRating = player.current_rating; + const errorRatingChange = player.rating_change; const errorData = { pdgaNumber: parseInt(pdgaNumber), name: player.name || 'Database Error', - rating: player.current_rating, - ratingChange: player.rating_change, - predictedRating: null + rating: errorRating, + ratingChange: errorRatingChange, + predictedRating: null, + stdDev: null, + lastMonthRating: (errorRating != null && errorRatingChange != null) ? errorRating - errorRatingChange : null, + deltaPredicted: null, + monthlyHistory: [] }; ratings.push(errorData); -- 2.52.0 From 3dcd3131a0815f4e469475606b5d71438b268b11 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 13:41:20 +0200 Subject: [PATCH 06/27] 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. --- public/css/shared.css | 41 +++++++++++++ public/js/players.js | 20 +++---- src/models/player.js | 98 +++++++++++++++++++++++++++----- src/services/player-service.js | 17 ++++-- views/partials/ratings-table.ejs | 27 +++++---- 5 files changed, 161 insertions(+), 42 deletions(-) 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 +<% } %> -- 2.52.0 From 6129b6fd3b02f3111b894a4b65c34321c432440a Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 13:49:30 +0200 Subject: [PATCH 07/27] feat: wire computeKpis into the page render (#5) --- src/routes/pages.js | 5 ++++- src/services/player-service.js | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/routes/pages.js b/src/routes/pages.js index cb22d9f..61d3c8d 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -1,10 +1,13 @@ const express = require('express'); const router = express.Router(); const { getTopbarLocals } = require('../services/topbar-service'); +const { getAllRatingsFromDB, computeKpis } = require('../services/player-service'); router.get('/', async (req, res) => { const topbar = await getTopbarLocals(); - res.render('index', { activePage: 'players', ...topbar }); + const players = await getAllRatingsFromDB(); + const kpis = computeKpis(players); + res.render('index', { activePage: 'players', kpis, ...topbar }); }); router.get('/courses', async (req, res) => { diff --git a/src/services/player-service.js b/src/services/player-service.js index 523016d..f73f7f8 100644 --- a/src/services/player-service.js +++ b/src/services/player-service.js @@ -292,10 +292,30 @@ async function refreshAllPlayersInDB(progressCallback = null) { } } +/** + * Aggregates KPI summary stats from an already-fetched player array. + * All fields are derived from the player list — no extra DB queries. + */ +function computeKpis(players) { + const active = players.filter(p => p.rating != null && p.rating > 0); + const avg = active.length > 0 + ? Math.round(active.reduce((sum, p) => sum + p.rating, 0) / active.length) + : null; + + return { + tracked: players.length, + active: active.length, + avg, + climbing: players.filter(p => p.ratingChange != null && p.ratingChange > 0).length, + slipping: players.filter(p => p.ratingChange != null && p.ratingChange < 0).length + }; +} + module.exports = { getPlayerDataFromDB, scrapePDGARating, getPredictedRatingFromDB, getAllRatingsFromDB, - refreshAllPlayersInDB + refreshAllPlayersInDB, + computeKpis }; -- 2.52.0 From b51ae19ae16f677538359f84a33080577d52e9f9 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 14:34:28 +0200 Subject: [PATCH 08/27] feat: render sparklines + wire trend-chart pill toggle (#6) --- public/css/shared.css | 69 ++++++++++++++++++++++++++++++++ public/js/players.js | 17 ++++++++ views/pages/index.ejs | 9 +++++ views/partials/ratings-table.ejs | 35 ++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/public/css/shared.css b/public/css/shared.css index f436b20..bc23b8b 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -592,3 +592,72 @@ a:hover { color: var(--ink-3); margin-top: 2px; } + +/* ── Table Toolbar + Pill Toggle ─────────────── */ + +.table-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 12px; +} + +.pill-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px 5px 10px; + border-radius: 999px; + border: 1px solid var(--line-2); + background: var(--paper-2); + color: var(--ink-2); + font-size: 12px; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease; +} + +.pill-toggle:hover { + background: var(--hover); + border-color: var(--line); +} + +.pill-toggle[aria-pressed="true"] { + background: color-mix(in oklab, var(--accent) 10%, white); + border-color: color-mix(in oklab, var(--accent) 35%, var(--line-2)); + color: var(--accent-text); +} + +.pill-icon { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.pill-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ink-3); + opacity: 0.4; + transition: background 150ms ease, opacity 150ms ease, box-shadow 150ms ease; +} + +.pill-toggle[aria-pressed="true"] .pill-dot { + background: var(--accent); + opacity: 1; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 20%, transparent); +} + +/* ── Sparklines ───────────────────────────────── */ + +.sparkline { + display: inline-block; + vertical-align: middle; + line-height: 0; + margin-top: 4px; +} + +body[data-sparklines="off"] .sparkline { + display: none; +} diff --git a/public/js/players.js b/public/js/players.js index 5741082..63a5806 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -480,3 +480,20 @@ function closeAddPlayerModal(event) { document.getElementById('add-player-modal').style.display = 'none'; pendingPlayerData = null; } + +// ── Sparkline toggle ─────────────────────────────── +document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('trendchart-toggle'); + if (!btn) return; + + var state = localStorage.getItem('ratingtracker.sparklines') || 'on'; + document.body.dataset.sparklines = state; + btn.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false'); + + btn.addEventListener('click', function() { + var next = document.body.dataset.sparklines === 'on' ? 'off' : 'on'; + document.body.dataset.sparklines = next; + btn.setAttribute('aria-pressed', next === 'on' ? 'true' : 'false'); + localStorage.setItem('ratingtracker.sparklines', next); + }); +}); diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 0287684..b1b5a58 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -19,6 +19,15 @@
+
+ +
`; %> diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index c73a84f..1795b94 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -1,3 +1,33 @@ +<% +function renderSparkline(values) { + if (!values || values.length < 2) return ''; + var w = 96, h = 28; + var min = Math.min.apply(null, values); + var max = Math.max.apply(null, values); + var range = max - min || 1; + var xStep = w / (values.length - 1); + + var pts = values.map(function(v, i) { + return { + x: (i * xStep).toFixed(1), + y: (((max - v) / range) * (h - 4) + 2).toFixed(1) + }; + }); + + var linePath = pts.map(function(p, i) { + return (i === 0 ? 'M' : 'L') + ' ' + p.x + ' ' + p.y; + }).join(' '); + + var last = pts[pts.length - 1]; + var areaPath = linePath + ' L ' + last.x + ' ' + h + ' L 0 ' + h + ' Z'; + + return '' + + '' + + '' + + '' + + ''; +} +%> <% if (ratings.length === 0) { %>

No ratings found.

<% } else { %> @@ -20,6 +50,8 @@ var deltaPredicted = player.deltaPredicted ?? null; var deltaPredictedPillText = deltaPredicted != null ? (deltaPredicted > 0 ? '+' + deltaPredicted : deltaPredicted.toString()) : null; var deltaPredictedPillClass = deltaPredicted > 0 ? 'up' : deltaPredicted < 0 ? 'down' : 'flat'; + + var sparklineSvg = renderSparkline(player.monthlyHistory || []); %> <%= index + 1 %> @@ -36,6 +68,9 @@ <% if (ratingChangePillText) { %> <%= ratingChangePillText %> <% } %> + <% if (sparklineSvg) { %> + <%- sparklineSvg %> + <% } %>
-- 2.52.0 From 83ceaf0ea368b174661551404a84112d54446688 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 13:58:48 +0200 Subject: [PATCH 09/27] feat: add KPI summary tiles above players table (#5) --- public/css/shared.css | 71 +++++++++++++++++++++++++++++++++++++++++++ views/pages/index.ejs | 37 ++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/public/css/shared.css b/public/css/shared.css index bc23b8b..1d38fc3 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -593,6 +593,77 @@ a:hover { margin-top: 2px; } +/* ── KPI Tiles ────────────────────────────────── */ + +.kpi-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 16px; + list-style: none; + padding: 0; +} + +.kpi-tile { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px 16px; + display: flex; + gap: 12px; + box-shadow: var(--shadow-card); +} + +.kpi-rail { + width: 3px; + align-self: stretch; + border-radius: 2px; + background: var(--ink-3); + flex-shrink: 0; +} + +.kpi-rail.up { + background: var(--up); +} + +.kpi-rail.down { + background: var(--down); +} + +.kpi-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.kpi-value { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--ink); + line-height: 1; +} + +.kpi-label { + font-size: 13px; + font-weight: 500; + color: var(--ink); +} + +.kpi-sub { + font-size: 12px; + color: var(--ink-3); +} + +@media (max-width: 880px) { + .kpi-strip { + grid-template-columns: repeat(2, 1fr); + } +} + /* ── Table Toolbar + Pill Toggle ─────────────── */ .table-toolbar { diff --git a/views/pages/index.ejs b/views/pages/index.ejs index b1b5a58..d4c8be0 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -16,6 +16,43 @@
+ + +
+
+ +
+
<%= kpis.tracked %>
+
Tracked
+
<%= kpis.active %> active
+
+
+
+ +
+
<%= kpis.avg ?? '—' %>
+
Avg rating
+
across active players
+
+
+
+ +
+
<%= kpis.climbing %>
+
Climbing
+
this month
+
+
+
+ +
+
<%= kpis.slipping %>
+
Slipping
+
this month
+
+
+
+
-- 2.52.0 From e5f16e624ec2c7211d61720df52ded71f9754fe6 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 14:00:02 +0200 Subject: [PATCH 10/27] feat: pass player record into player-history partial render (#7) --- src/routes/players.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/players.js b/src/routes/players.js index fddfc0c..4d7ecc4 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -64,7 +64,8 @@ router.get('/partials/player-history/:pdgaNumber', async (req, res) => { displayDate: new Date(row.date).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' }) })); - res.render('../partials/player-history', { pdgaNumber, history: formattedHistory }); + const player = await getPlayerDataFromDB(pdgaNumber); + res.render('../partials/player-history', { pdgaNumber, history: formattedHistory, player }); } catch (error) { logger.error('Error loading player history:', error.message); res.status(500).send('
Error loading rating history
'); -- 2.52.0 From b75e60da65ff8d87e5a78a56cf904391f7438385 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 14:03:52 +0200 Subject: [PATCH 11/27] feat: redesign expanded row with detail-grid + history chart (#7) --- public/css/shared.css | 89 +++++++++- public/js/chart.js | 260 ++++++++++++++---------------- public/js/players.js | 50 +++++- views/partials/player-history.ejs | 63 ++++++-- views/partials/ratings-table.ejs | 2 +- 5 files changed, 308 insertions(+), 156 deletions(-) diff --git a/public/css/shared.css b/public/css/shared.css index 1d38fc3..c619efc 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -364,14 +364,99 @@ tr:last-child td { display: none; } +@keyframes expandIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.expanded-content.is-open { + animation: expandIn 0.2s ease; +} + .expanded-content td { padding: 0; - background: var(--surface-2); + background: color-mix(in oklab, var(--accent) 4%, var(--paper-2)); border-top: 2px solid var(--accent); } .expanded-cell { - padding: 20px !important; + padding: 22px 28px 28px !important; +} + +tr.row-open { + box-shadow: inset 3px 0 0 var(--accent); +} + +/* ── Expanded Row Detail Grid ─────────────────── */ + +.player-detail { + display: grid; + grid-template-columns: 240px 1fr; + gap: 28px; + align-items: start; +} + +@media (max-width: 880px) { + .player-detail { + grid-template-columns: 1fr; + } +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr; + margin: 0; + padding: 0; + list-style: none; +} + +.detail-grid > div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 8px 0; + border-bottom: 1px dashed var(--line); +} + +.detail-grid > div:last-child { + border-bottom: none; +} + +.detail-grid dt { + color: var(--ink-2); + font-size: 13px; + font-weight: 400; + flex-shrink: 0; +} + +.detail-grid dd { + color: var(--ink); + font-size: 13px; + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + margin: 0; + text-align: right; +} + +.player-chart { + width: 100%; + max-width: 880px; +} + +.link-btn { + background: none; + border: none; + padding: 0; + font-family: var(--font-sans); + font-size: 13px; + cursor: pointer; + color: var(--accent); +} + +.link-btn:disabled { + color: var(--ink-3); + cursor: default; } /* ── Links ────────────────────────────────────── */ diff --git a/public/js/chart.js b/public/js/chart.js index abbf382..09cc5d4 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -1,150 +1,138 @@ function createRatingChart(container, history) { - const width = container.clientWidth; - const height = 280; - const margin = { top: 24, right: 20, bottom: 44, left: 56 }; - const chartWidth = width - margin.left - margin.right; - const chartHeight = height - margin.top - margin.bottom; - - const pdgaNumber = container.id.replace('chart-', ''); - const tooltip = document.getElementById(`tooltip-${pdgaNumber}`); - - const ratings = history.map(h => h.rating); - const minRating = Math.min(...ratings) - 10; - const maxRating = Math.max(...ratings) + 10; - - const xStep = chartWidth / (history.length - 1 || 1); - const yScale = chartHeight / (maxRating - minRating); - - // Build points array - const points = []; - let pathData = ''; - - history.forEach((point, i) => { - const x = margin.left + (i * xStep); - const y = margin.top + ((maxRating - point.rating) * yScale); - points.push({ x, y, rating: point.rating, date: point.displayDate }); - pathData += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`; - }); - - // Area fill path (close to bottom) - const areaPath = pathData + - ` L ${points[points.length - 1].x} ${margin.top + chartHeight}` + - ` L ${points[0].x} ${margin.top + chartHeight} Z`; - - let svg = ``; - - // Defs: gradient + glow - svg += ` - - - - - - - - - `; - - // Background - svg += ``; - - // Grid lines - const gridCount = 5; - for (let i = 0; i <= gridCount; i++) { - const y = margin.top + (i * chartHeight / gridCount); - const rating = Math.round(maxRating - (i * (maxRating - minRating) / gridCount)); - - svg += ``; - svg += `${rating}`; + if (!history || history.length === 0) { + container.textContent = ''; + var empty = document.createElement('div'); + empty.className = 'loading-chart'; + empty.textContent = 'No rating history available'; + container.appendChild(empty); + return; } - // Area fill - svg += ``; + var W = 880, H = 240; + var pad = { left: 44, right: 16, top: 20, bottom: 32 }; + var chartW = W - pad.left - pad.right; + var chartH = H - pad.top - pad.bottom; - // Line - svg += ``; + var ratings = history.map(function(h) { return h.rating; }); + var minR = Math.min.apply(null, ratings) - 5; + var maxR = Math.max.apply(null, ratings) + 5; + var range = maxR - minR || 1; - // Data points - points.forEach((point, i) => { - svg += ``; - svg += ``; + function xOf(i) { + return pad.left + (i / Math.max(history.length - 1, 1)) * chartW; + } + function yOf(r) { + return pad.top + ((maxR - r) / range) * chartH; + } + + var pts = history.map(function(h, i) { + return { x: xOf(i), y: yOf(h.rating), rating: h.rating, date: h.date }; }); - // X-axis labels - const labelStep = Math.max(1, Math.floor(history.length / 6)); - history.forEach((point, i) => { - if (i % labelStep === 0 || i === history.length - 1) { - const x = margin.left + (i * xStep); - const date = new Date(point.date).toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); - svg += `${date}`; + var linePath = pts.map(function(p, i) { + return (i === 0 ? 'M' : 'L') + ' ' + p.x.toFixed(1) + ' ' + p.y.toFixed(1); + }).join(' '); + + var last = pts[pts.length - 1]; + var bottomY = (pad.top + chartH).toFixed(1); + var areaPath = linePath + + ' L ' + last.x.toFixed(1) + ' ' + bottomY + + ' L ' + pad.left.toFixed(1) + ' ' + bottomY + ' Z'; + + // Build SVG using DOM API to avoid innerHTML on user-supplied content + var ns = 'http://www.w3.org/2000/svg'; + + function el(tag, attrs) { + var e = document.createElementNS(ns, tag); + Object.keys(attrs).forEach(function(k) { e.setAttribute(k, attrs[k]); }); + return e; + } + + function txt(tag, attrs, text) { + var e = el(tag, attrs); + e.textContent = text; + return e; + } + + var svg = el('svg', { + viewBox: '0 0 ' + W + ' ' + H, + width: '100%', + style: 'display:block;overflow:visible', + 'aria-hidden': 'true' + }); + + // Grid lines + y-axis labels (4 ticks) + var tickCount = 4; + for (var i = 0; i <= tickCount; i++) { + var gy = pad.top + (i / tickCount) * chartH; + var gr = Math.round(maxR - (i / tickCount) * range); + svg.appendChild(el('line', { + x1: pad.left, y1: gy.toFixed(1), + x2: pad.left + chartW, y2: gy.toFixed(1), + stroke: 'var(--line)', 'stroke-width': '1', 'stroke-dasharray': '2 4' + })); + svg.appendChild(txt('text', { + x: pad.left - 8, y: (gy + 4).toFixed(1), + 'text-anchor': 'end', 'font-size': '10', + 'font-family': "'JetBrains Mono', monospace", + fill: 'var(--ink-3)' + }, String(gr))); + } + + // Area fill (8% opacity) + svg.appendChild(el('path', { + d: areaPath, fill: 'var(--accent)', 'fill-opacity': '0.08' + })); + + // Line + svg.appendChild(el('path', { + d: linePath, stroke: 'var(--accent)', 'stroke-width': '2', + fill: 'none', 'stroke-linejoin': 'round', 'stroke-linecap': 'round' + })); + + // Dots + pts.forEach(function(p, i) { + var isLast = i === pts.length - 1; + if (isLast) { + svg.appendChild(el('circle', { + cx: p.x.toFixed(1), cy: p.y.toFixed(1), + r: '4', fill: 'var(--accent)', stroke: 'white', 'stroke-width': '2' + })); + } else { + svg.appendChild(el('circle', { + cx: p.x.toFixed(1), cy: p.y.toFixed(1), + r: '3', fill: 'var(--accent)' + })); } }); - // Y-axis label - svg += `Rating`; + // X-axis labels (5 evenly spaced) + var labelCount = Math.min(5, history.length); + var labelIndices = []; + if (labelCount <= 1) { + labelIndices.push(0); + } else { + for (var k = 0; k < labelCount; k++) { + labelIndices.push(Math.round(k * (history.length - 1) / (labelCount - 1))); + } + } - svg += ''; + var seen = {}; + labelIndices.forEach(function(idx) { + if (seen[idx]) return; + seen[idx] = true; + var p = pts[idx]; + var d = new Date(history[idx].date); + var label = d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + svg.appendChild(txt('text', { + x: p.x.toFixed(1), + y: (pad.top + chartH + 16).toFixed(1), + 'text-anchor': 'middle', 'font-size': '10', + 'font-family': "'JetBrains Mono', monospace", + fill: 'var(--ink-3)' + }, label)); + }); container.textContent = ''; - container.insertAdjacentHTML('beforeend', svg); - - setTimeout(() => { - const svgElement = document.getElementById(`svg-${pdgaNumber}`) || container.querySelector('svg'); - if (!svgElement) return; - - const hoverAreas = svgElement.querySelectorAll('.hover-area'); - const dataPoints = svgElement.querySelectorAll('.data-point'); - - let currentTooltip = null; - let tooltipTimeout = null; - - hoverAreas.forEach((area, i) => { - area.addEventListener('mouseenter', (e) => { - if (tooltipTimeout) { - clearTimeout(tooltipTimeout); - tooltipTimeout = null; - } - - if (currentTooltip !== null && currentTooltip !== i) { - dataPoints[currentTooltip].setAttribute('r', '3.5'); - dataPoints[currentTooltip].setAttribute('fill', '#3b82f6'); - } - - currentTooltip = i; - const point = points[i]; - - tooltip.textContent = ''; - const strong = document.createElement('strong'); - strong.textContent = point.date; - tooltip.appendChild(strong); - tooltip.appendChild(document.createElement('br')); - tooltip.appendChild(document.createTextNode('Rating: ' + point.rating)); - - tooltip.style.display = 'block'; - tooltip.style.left = `${e.clientX + 15}px`; - tooltip.style.top = `${e.clientY - 35}px`; - - dataPoints[i].setAttribute('r', '6'); - dataPoints[i].setAttribute('fill', '#2563eb'); - }); - - area.addEventListener('mousemove', (e) => { - if (currentTooltip === i) { - tooltip.style.left = `${e.clientX + 15}px`; - tooltip.style.top = `${e.clientY - 35}px`; - } - }); - - area.addEventListener('mouseleave', () => { - if (currentTooltip === i) { - tooltipTimeout = setTimeout(() => { - tooltip.style.display = 'none'; - dataPoints[i].setAttribute('r', '3.5'); - dataPoints[i].setAttribute('fill', '#3b82f6'); - currentTooltip = null; - }, 100); - } - }); - }); - }, 100); + container.appendChild(svg); } diff --git a/public/js/players.js b/public/js/players.js index 63a5806..e622de3 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -1,5 +1,6 @@ -let cachedDebugInfo = {}; -let pendingPlayerData = null; +var cachedDebugInfo = {}; +var pendingPlayerData = null; +var openPdgaNumber = null; function setupTooltipsAfterSwap() { document.body.addEventListener('htmx:afterSwap', function(event) { @@ -9,7 +10,7 @@ function setupTooltipsAfterSwap() { // After player history partial loads, render the chart const target = event.detail.target; if (target.id && target.id.startsWith('history-content-')) { - const container = target.querySelector('.chart-container'); + const container = target.querySelector('.player-chart, .chart-container'); if (container && container.dataset.history) { try { const history = JSON.parse(container.dataset.history); @@ -48,21 +49,46 @@ function initRatingsTooltips() { } function togglePlayerHistory(pdgaNumber) { - const historyRow = document.getElementById(`history-${pdgaNumber}`); - const contentDiv = document.getElementById(`history-content-${pdgaNumber}`); + var historyRow = document.getElementById('history-' + pdgaNumber); + var contentDiv = document.getElementById('history-content-' + pdgaNumber); + var expandableRow = document.getElementById('row-' + pdgaNumber); - if (historyRow.style.display === 'table-row') { + var isOpen = historyRow.style.display === 'table-row'; + + // Close any previously-open row + if (openPdgaNumber !== null && openPdgaNumber !== pdgaNumber) { + var prevHistory = document.getElementById('history-' + openPdgaNumber); + var prevRow = document.getElementById('row-' + openPdgaNumber); + if (prevHistory) { + prevHistory.style.display = 'none'; + prevHistory.classList.remove('is-open'); + } + if (prevRow) prevRow.classList.remove('row-open'); + openPdgaNumber = null; + } + + if (isOpen) { historyRow.style.display = 'none'; + historyRow.classList.remove('is-open'); + expandableRow.classList.remove('row-open'); + openPdgaNumber = null; return; } historyRow.style.display = 'table-row'; + // Force reflow so animation plays each open + historyRow.classList.remove('is-open'); + void historyRow.offsetWidth; + historyRow.classList.add('is-open'); + + expandableRow.classList.add('row-open'); + openPdgaNumber = pdgaNumber; if (contentDiv.dataset.loaded === 'true') { return; } - htmx.ajax('GET', `/partials/player-history/${pdgaNumber}`, {target: `#history-content-${pdgaNumber}`, swap: 'innerHTML'}); + htmx.ajax('GET', '/partials/player-history/' + pdgaNumber, {target: '#history-content-' + pdgaNumber, swap: 'innerHTML'}); contentDiv.dataset.loaded = 'true'; } @@ -497,3 +523,13 @@ document.addEventListener('DOMContentLoaded', function() { localStorage.setItem('ratingtracker.sparklines', next); }); }); + +// ── Expandable row keyboard support ─────────────── +document.addEventListener('keydown', function(e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + var row = e.target; + if (!row.classList || !row.classList.contains('expandable-row')) return; + e.preventDefault(); + var pdgaNumber = row.id.replace('row-', ''); + togglePlayerHistory(parseInt(pdgaNumber, 10)); +}); diff --git a/views/partials/player-history.ejs b/views/partials/player-history.ejs index 9bb0edf..ba627d1 100644 --- a/views/partials/player-history.ejs +++ b/views/partials/player-history.ejs @@ -1,12 +1,55 @@ -<% if (history && history.length > 0) { %> -
-
Loading chart...
+<% +var monthChange = (typeof player !== 'undefined' && player) ? player.ratingChange : null; +var monthPillText = monthChange != null ? (monthChange > 0 ? '+' + monthChange : monthChange.toString()) : null; +var monthPillClass = monthChange > 0 ? 'up' : monthChange < 0 ? 'down' : 'flat'; + +var gapPredicted = (typeof player !== 'undefined' && player) ? (player.deltaPredicted ?? null) : null; +var gapPillText = gapPredicted != null ? (gapPredicted > 0 ? '+' + gapPredicted : gapPredicted.toString()) : null; +var gapPillClass = gapPredicted > 0 ? 'up' : gapPredicted < 0 ? 'down' : 'flat'; + +var hasPlayer = (typeof player !== 'undefined' && player); +var chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber; +%> +
+ <% if (hasPlayer) { %> +
+
+
Current rating
+
<%= player.rating ?? '—' %>
-
-<% } else { %> -
-
No rating history available
+
+
Last month
+
<%= player.lastMonthRating ?? '—' %>
-<% } %> +
+
Change vs last month
+
+ <% if (monthPillText) { %> + <%= monthPillText %> + <% } else { %>—<% } %> +
+
+
+
Predicted next update
+
<%= player.predictedRating ?? '—' %>
+
+
+
Gap to predicted
+
+ <% if (gapPillText) { %> + <%= gapPillText %> + <% } else { %>—<% } %> +
+
+
+ <% } %> + + <% if (history && history.length > 0) { %> +
+
+ <% } else { %> +
No rating history available
+ <% } %> +
+
diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index 1795b94..d6bf061 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -53,7 +53,7 @@ function renderSparkline(values) { var sparklineSvg = renderSparkline(player.monthlyHistory || []); %> - + <%= index + 1 %> <%= player.name %> -- 2.52.0 From 9df151f10990c2b30265401095cd052255f75bd9 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 14:11:41 +0200 Subject: [PATCH 12/27] fix: return latest N months in getMonthlyHistory (#6) --- src/models/player.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/player.js b/src/models/player.js index 8f94c8d..5460378 100644 --- a/src/models/player.js +++ b/src/models/player.js @@ -205,12 +205,12 @@ function getMonthlyHistory(pdgaNumber, months = 12) { WHERE player_id = ? GROUP BY strftime('%Y-%m', date) ) - ORDER BY date ASC + ORDER BY date DESC LIMIT ?`, [player.id, player.id, months], (err, rows) => { if (err) return reject(err); - resolve(rows.map(r => r.rating)); + resolve(rows.map(r => r.rating).reverse()); } ); }); -- 2.52.0 From 0ded27f9df937cb343c7ca83d65044f6f032ce07 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 14:16:58 +0200 Subject: [PATCH 13/27] =?UTF-8?q?fix:=20address=20code=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20DRY=20delta-pill,=20var=E2=86=92const/let,=20tok?= =?UTF-8?q?enize=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/shared.css | 2 +- public/js/chart.js | 2 +- public/js/players.js | 47 ++++++++++++++++++------------- views/partials/delta-pill.ejs | 10 +++++++ views/partials/player-history.ejs | 20 +++---------- views/partials/ratings-table.ejs | 18 ++---------- 6 files changed, 47 insertions(+), 52 deletions(-) create mode 100644 views/partials/delta-pill.ejs diff --git a/public/css/shared.css b/public/css/shared.css index c619efc..e8a7d67 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -662,7 +662,7 @@ a:hover { } .delta-pill.flat { - background: oklch(0.95 0.004 260); + background: var(--paper-2); color: var(--ink-3); } diff --git a/public/js/chart.js b/public/js/chart.js index 09cc5d4..970d99a 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -96,7 +96,7 @@ function createRatingChart(container, history) { if (isLast) { svg.appendChild(el('circle', { cx: p.x.toFixed(1), cy: p.y.toFixed(1), - r: '4', fill: 'var(--accent)', stroke: 'white', 'stroke-width': '2' + r: '4', fill: 'var(--accent)', stroke: 'var(--paper)', 'stroke-width': '2' })); } else { svg.appendChild(el('circle', { diff --git a/public/js/players.js b/public/js/players.js index e622de3..bea9bc1 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -1,6 +1,17 @@ -var cachedDebugInfo = {}; -var pendingPlayerData = null; -var openPdgaNumber = null; +const cachedDebugInfo = {}; +let pendingPlayerData = null; +let openPdgaNumber = null; + +// ── Delta-pill helper ───────────────────────────── +// Returns { text, cls, html } — use .text/.cls to update existing DOM elements +// safely; .html is available for insertion contexts. +function renderDeltaPill(value, extraClass) { + if (value == null) return null; + const cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; + const text = value > 0 ? '+' + value : String(value); + const extra = extraClass ? ' ' + extraClass : ''; + return { text, cls, html: `${text}` }; +} function setupTooltipsAfterSwap() { document.body.addEventListener('htmx:afterSwap', function(event) { @@ -49,16 +60,16 @@ function initRatingsTooltips() { } function togglePlayerHistory(pdgaNumber) { - var historyRow = document.getElementById('history-' + pdgaNumber); - var contentDiv = document.getElementById('history-content-' + pdgaNumber); - var expandableRow = document.getElementById('row-' + pdgaNumber); + const historyRow = document.getElementById('history-' + pdgaNumber); + const contentDiv = document.getElementById('history-content-' + pdgaNumber); + const expandableRow = document.getElementById('row-' + pdgaNumber); - var isOpen = historyRow.style.display === 'table-row'; + const isOpen = historyRow.style.display === 'table-row'; // Close any previously-open row if (openPdgaNumber !== null && openPdgaNumber !== pdgaNumber) { - var prevHistory = document.getElementById('history-' + openPdgaNumber); - var prevRow = document.getElementById('row-' + openPdgaNumber); + const prevHistory = document.getElementById('history-' + openPdgaNumber); + const prevRow = document.getElementById('row-' + openPdgaNumber); if (prevHistory) { prevHistory.style.display = 'none'; prevHistory.classList.remove('is-open'); @@ -144,11 +155,9 @@ async function refreshPlayer(pdgaNumber) { 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}`; + const pill = renderDeltaPill(data.player.ratingChange); + deltaMonthPill.textContent = pill.text; + deltaMonthPill.className = `delta-pill ${pill.cls}`; } } } catch (error) { @@ -509,15 +518,15 @@ function closeAddPlayerModal(event) { // ── Sparkline toggle ─────────────────────────────── document.addEventListener('DOMContentLoaded', function() { - var btn = document.getElementById('trendchart-toggle'); + const btn = document.getElementById('trendchart-toggle'); if (!btn) return; - var state = localStorage.getItem('ratingtracker.sparklines') || 'on'; + const state = localStorage.getItem('ratingtracker.sparklines') || 'on'; document.body.dataset.sparklines = state; btn.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false'); btn.addEventListener('click', function() { - var next = document.body.dataset.sparklines === 'on' ? 'off' : 'on'; + const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on'; document.body.dataset.sparklines = next; btn.setAttribute('aria-pressed', next === 'on' ? 'true' : 'false'); localStorage.setItem('ratingtracker.sparklines', next); @@ -527,9 +536,9 @@ document.addEventListener('DOMContentLoaded', function() { // ── Expandable row keyboard support ─────────────── document.addEventListener('keydown', function(e) { if (e.key !== 'Enter' && e.key !== ' ') return; - var row = e.target; + const row = e.target; if (!row.classList || !row.classList.contains('expandable-row')) return; e.preventDefault(); - var pdgaNumber = row.id.replace('row-', ''); + const pdgaNumber = row.id.replace('row-', ''); togglePlayerHistory(parseInt(pdgaNumber, 10)); }); diff --git a/views/partials/delta-pill.ejs b/views/partials/delta-pill.ejs new file mode 100644 index 0000000..819e94e --- /dev/null +++ b/views/partials/delta-pill.ejs @@ -0,0 +1,10 @@ +<%/* delta-pill.ejs — renders a Δ-pill span. + Locals: + value {number|null} — the delta value + extraClass {string} — optional additional CSS class (e.g. 'delta-predicted-pill') +*/%> +<% if (typeof value !== 'undefined' && value != null) { + const _cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; + const _text = value > 0 ? '+' + value : value.toString(); + const _xtra = (typeof extraClass !== 'undefined' && extraClass) ? ' ' + extraClass : ''; +%><%= _text %><% } %> diff --git a/views/partials/player-history.ejs b/views/partials/player-history.ejs index ba627d1..fdbda08 100644 --- a/views/partials/player-history.ejs +++ b/views/partials/player-history.ejs @@ -1,14 +1,6 @@ <% -var monthChange = (typeof player !== 'undefined' && player) ? player.ratingChange : null; -var monthPillText = monthChange != null ? (monthChange > 0 ? '+' + monthChange : monthChange.toString()) : null; -var monthPillClass = monthChange > 0 ? 'up' : monthChange < 0 ? 'down' : 'flat'; - -var gapPredicted = (typeof player !== 'undefined' && player) ? (player.deltaPredicted ?? null) : null; -var gapPillText = gapPredicted != null ? (gapPredicted > 0 ? '+' + gapPredicted : gapPredicted.toString()) : null; -var gapPillClass = gapPredicted > 0 ? 'up' : gapPredicted < 0 ? 'down' : 'flat'; - -var hasPlayer = (typeof player !== 'undefined' && player); -var chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber; +const hasPlayer = (typeof player !== 'undefined' && player); +const chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber; %>
<% if (hasPlayer) { %> @@ -24,9 +16,7 @@ var chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber;
Change vs last month
- <% if (monthPillText) { %> - <%= monthPillText %> - <% } else { %>—<% } %> + <% if (player.ratingChange != null) { %><%- include('delta-pill', { value: player.ratingChange }) %><% } else { %>—<% } %>
@@ -36,9 +26,7 @@ var chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber;
Gap to predicted
- <% if (gapPillText) { %> - <%= gapPillText %> - <% } else { %>—<% } %> + <% if (player.deltaPredicted != null) { %><%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %><% } else { %>—<% } %>
diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index d6bf061..e8df398 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -43,15 +43,7 @@ function renderSparkline(values) { <% ratings.forEach(function(player, index) { - 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'; - - var sparklineSvg = renderSparkline(player.monthlyHistory || []); + const sparklineSvg = renderSparkline(player.monthlyHistory || []); %> <%= index + 1 %> @@ -65,9 +57,7 @@ function renderSparkline(values) { <%- player.rating || 'Click refresh' %>
- <% if (ratingChangePillText) { %> - <%= ratingChangePillText %> - <% } %> + <%- include('delta-pill', { value: player.ratingChange }) %> <% if (sparklineSvg) { %> <%- sparklineSvg %> <% } %> @@ -79,9 +69,7 @@ function renderSparkline(values) {
- <% if (deltaPredictedPillText) { %> - <%= deltaPredictedPillText %> - <% } %> + <%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %>
-- 2.52.0 From cc223a4b8aac3057912c3fabc3fb6d4a81ed1b3f Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 14:23:16 +0200 Subject: [PATCH 14/27] fix: drop unused html field from renderDeltaPill (#3) --- public/js/players.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/public/js/players.js b/public/js/players.js index bea9bc1..9701eb1 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -3,14 +3,11 @@ let pendingPlayerData = null; let openPdgaNumber = null; // ── Delta-pill helper ───────────────────────────── -// Returns { text, cls, html } — use .text/.cls to update existing DOM elements -// safely; .html is available for insertion contexts. function renderDeltaPill(value, extraClass) { if (value == null) return null; const cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; const text = value > 0 ? '+' + value : String(value); - const extra = extraClass ? ' ' + extraClass : ''; - return { text, cls, html: `${text}` }; + return { text, cls }; } function setupTooltipsAfterSwap() { -- 2.52.0 From ac6008aa14cef8f455f8b45619c761919b457f0d Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 14:33:14 +0200 Subject: [PATCH 15/27] chore: delete dead progress.js (#4) --- public/js/progress.js | 97 ------------------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 public/js/progress.js diff --git a/public/js/progress.js b/public/js/progress.js deleted file mode 100644 index 4f9b18a..0000000 --- a/public/js/progress.js +++ /dev/null @@ -1,97 +0,0 @@ -function fetchRatingsWithProgress() { - const progressSection = document.getElementById('progress-section'); - const progressBar = document.getElementById('progress-bar'); - const progressText = document.getElementById('progress-text'); - const tableDiv = document.getElementById('ratings-table'); - - progressSection.style.display = 'block'; - tableDiv.innerHTML = ''; - - const eventSource = new EventSource('/api/ratings/progress'); - - eventSource.onmessage = function(event) { - const data = JSON.parse(event.data); - - if (data.status === 'loading') { - const percentage = Math.round((data.current / data.total) * 100); - progressBar.style.width = `${percentage}%`; - progressBar.textContent = `${percentage}%`; - progressText.textContent = `Loading player ${data.current}/${data.total}: PDGA #${data.pdgaNumber}`; - } else if (data.status === 'completed') { - const percentage = Math.round((data.current / data.total) * 100); - progressBar.style.width = `${percentage}%`; - progressBar.textContent = `${percentage}%`; - progressText.textContent = `Loaded ${data.name} (${data.current}/${data.total})`; - } else if (data.status === 'error') { - const percentage = Math.round((data.current / data.total) * 100); - progressBar.style.width = `${percentage}%`; - progressBar.textContent = `${percentage}%`; - progressText.textContent = `Error loading PDGA #${data.pdgaNumber} (${data.current}/${data.total})`; - } else if (data.status === 'complete') { - progressSection.style.display = 'none'; - htmx.ajax('GET', '/partials/ratings-table', '#ratings-table'); - eventSource.close(); - } - }; - - eventSource.onerror = function() { - progressSection.style.display = 'none'; - tableDiv.textContent = 'Connection error. Please refresh the page.'; - eventSource.close(); - }; -} - -function loadAllPlayers() { - const button = document.getElementById('load-all-btn'); - const originalText = button.textContent; - button.textContent = 'Loading...'; - button.style.pointerEvents = 'none'; - - try { - const progressSection = document.getElementById('progress-section'); - const progressBar = document.getElementById('progress-bar'); - const progressText = document.getElementById('progress-text'); - const tableDiv = document.getElementById('ratings-table'); - - progressSection.style.display = 'block'; - tableDiv.textContent = ''; - - const eventSource = new EventSource('/api/load-all-players'); - - eventSource.onmessage = function(event) { - const data = JSON.parse(event.data); - - if (data.status === 'loading' || data.status === 'completed' || data.status === 'error') { - const percentage = Math.round((data.current / data.total) * 100); - progressBar.style.width = `${percentage}%`; - progressBar.textContent = `${percentage}%`; - - if (data.status === 'loading') { - progressText.textContent = `Loading player ${data.current}/${data.total}: PDGA #${data.pdgaNumber}`; - } else if (data.status === 'completed') { - progressText.textContent = `Loaded ${data.name} (${data.current}/${data.total})`; - } else if (data.status === 'error') { - progressText.textContent = `Error loading PDGA #${data.pdgaNumber} (${data.current}/${data.total})`; - } - } else if (data.status === 'complete') { - progressSection.style.display = 'none'; - htmx.ajax('GET', '/partials/ratings-table', '#ratings-table'); - eventSource.close(); - button.textContent = originalText; - button.style.pointerEvents = 'auto'; - } - }; - - eventSource.onerror = function() { - progressSection.style.display = 'none'; - tableDiv.textContent = 'Connection error. Please refresh the page.'; - eventSource.close(); - button.textContent = originalText; - button.style.pointerEvents = 'auto'; - }; - } catch (error) { - console.error('Error loading all players:', error); - button.textContent = originalText; - button.style.pointerEvents = 'auto'; - } -} -- 2.52.0 From 686d7ca00ce7f08bf399a0e8a6346ea250bb84de Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:03:02 +0200 Subject: [PATCH 16/27] fix: use template-literal interpolation for KPI strip inside body string (#5) The page body is assembled as a JS template literal inside <% ... %>; EJS tags inside that string break the EJS parser (it sees the first %> as the close of the outer tag). Switch to ${kpis.x} interpolation since we're already inside a backtick string. --- views/pages/index.ejs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/views/pages/index.ejs b/views/pages/index.ejs index d4c8be0..1c4898b 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -22,15 +22,15 @@
-
<%= kpis.tracked %>
+
${kpis.tracked}
Tracked
-
<%= kpis.active %> active
+
${kpis.active} active
-
<%= kpis.avg ?? '—' %>
+
${kpis.avg ?? '—'}
Avg rating
across active players
@@ -38,7 +38,7 @@
-
<%= kpis.climbing %>
+
${kpis.climbing}
Climbing
this month
@@ -46,7 +46,7 @@
-
<%= kpis.slipping %>
+
${kpis.slipping}
Slipping
this month
-- 2.52.0 From 16d375ae10d7eca7567a6f189b82adde2b0bd1d5 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:15:29 +0200 Subject: [PATCH 17/27] refactor: design-fidelity pass on players page --- public/css/players.css | 16 +- public/css/shared.css | 432 ++++++++++++++++++++++++------- public/js/players.js | 55 ++-- views/pages/index.ejs | 68 +++-- views/partials/delta-pill.ejs | 9 +- views/partials/layout.ejs | 2 +- views/partials/ratings-table.ejs | 70 ++--- 7 files changed, 463 insertions(+), 189 deletions(-) diff --git a/public/css/players.css b/public/css/players.css index 7a512f7..5a68d0d 100644 --- a/public/css/players.css +++ b/public/css/players.css @@ -2,15 +2,7 @@ Players Page ═══════════════════════════════════════════════════ */ -/* ── Add Player Button ─────────────────────────── */ - -.btn-add { - background: var(--green); -} - -.btn-add:hover { - background: #059669; -} +/* ── (Add player now uses .btn-primary from shared.css) ── */ /* ── Mobile helpers ───────────────────────────── */ @@ -30,12 +22,6 @@ /* ── Rating values ────────────────────────────── */ -.rating { - font-weight: 700; - color: var(--accent); - font-variant-numeric: tabular-nums; -} - .rating-value { font-variant-numeric: tabular-nums; } diff --git a/public/css/shared.css b/public/css/shared.css index e8a7d67..204cc57 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -293,9 +293,12 @@ body { /* ── Container ────────────────────────────────── */ .container { - max-width: 960px; + max-width: 1180px; margin: 0 auto; - padding: 28px 24px 60px; + padding: 28px 32px 60px; + display: flex; + flex-direction: column; + gap: 22px; } .page-title { @@ -303,7 +306,7 @@ body { font-weight: 700; color: var(--text-primary); letter-spacing: -0.03em; - margin: 0 0 24px 0; + margin: 0; } /* ── Tables ───────────────────────────────────── */ @@ -322,28 +325,23 @@ thead { } th { - padding: 10px 16px; + padding: 0 16px; + height: 48px; text-align: left; font-weight: 600; font-size: 11px; text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-secondary); - background: var(--surface-2); - border-bottom: 1px solid var(--border); -} - -th:first-child { - border-radius: var(--radius-md) 0 0 0; -} - -th:last-child { - border-radius: 0 var(--radius-md) 0 0; + letter-spacing: 0.08em; + color: var(--ink-3); + background: var(--paper-2); + border-bottom: 1px solid var(--line); + vertical-align: middle; } td { - padding: 12px 16px; - border-bottom: 1px solid var(--border-light); + padding: 0 16px; + height: 64px; + border-bottom: 1px solid var(--line); vertical-align: middle; } @@ -351,13 +349,116 @@ tr:last-child td { border-bottom: none; } +/* Column widths */ +.col-rank { width: 56px; text-align: center; } +.col-player { min-width: 200px; } +.col-rating { min-width: 220px; } +.col-predicted { min-width: 180px; } +.col-actions { width: 72px; } + +/* Rank number */ +.rank-num { + font-size: 13px; + color: var(--ink-3); + font-weight: 500; +} + +/* Player name cell */ +.cell-name { + display: flex; + flex-direction: column; + gap: 2px; +} + +.player-name a { + font-weight: 600; + font-size: 14px; + letter-spacing: -0.005em; + color: var(--ink); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +.player-name a:hover { + color: var(--accent); +} + +.pdga-num { + font-size: 11px; + color: var(--ink-3); +} + +/* Rating cell stack */ +.cell-rating { + vertical-align: middle; +} + +.rating-stack { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +.rating-big { + font-size: 20px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.02em; + line-height: 1; +} + +.rating-pending { + font-size: 12px; + font-style: italic; + color: var(--ink-3); +} + +/* Predicted cell stack */ +.pred-stack { + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-start; +} + +.pred-num { + font-size: 20px; + font-weight: 600; + color: var(--ink-2); + letter-spacing: -0.02em; + line-height: 1; +} + +/* Actions cell */ +.cell-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +/* Chevron rotation when row open */ +tr.row-open .icon-chev i { + transform: rotate(180deg); +} + +.icon-chev i { + transition: transform 180ms ease; +} + .expandable-row { cursor: pointer; - transition: background var(--transition); + transition: background 120ms ease; + outline: none; + position: relative; } .expandable-row:hover { - background: var(--accent-subtle); + background: var(--hover); } .expanded-content { @@ -376,7 +477,7 @@ tr:last-child td { .expanded-content td { padding: 0; background: color-mix(in oklab, var(--accent) 4%, var(--paper-2)); - border-top: 2px solid var(--accent); + border-bottom: 1px solid var(--line); } .expanded-cell { @@ -506,31 +607,129 @@ a:hover { box-shadow: none; } -/* ── Card Section ─────────────────────────────── */ +/* ── Add Player Bar ───────────────────────────── */ -.card-section { - background: var(--surface-1); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 24px; - margin-bottom: 28px; - box-shadow: var(--shadow-sm); -} - -.card-section h3 { - margin: 0 0 14px 0; - color: var(--text-primary); - font-size: 15px; - font-weight: 600; - text-align: center; -} - -.card-section-form { +.add-bar { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 20px 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + box-shadow: var(--shadow-card); +} + +.add-bar-label { + display: flex; + flex-direction: column; + gap: 2px; +} + +.add-bar-kicker { + font-weight: 700; + font-size: 16px; + letter-spacing: -0.01em; + color: var(--ink); +} + +.add-bar-hint { + font-size: 12px; + color: var(--ink-3); +} + +.add-bar-controls { display: flex; - justify-content: center; align-items: center; gap: 10px; - flex-wrap: wrap; + flex-shrink: 0; +} + +.input-wrap { + position: relative; + display: flex; + align-items: center; + background: var(--paper-2); + border: 1px solid var(--line-2); + border-radius: 10px; + height: 40px; + width: 220px; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.input-wrap:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 14%, transparent); +} + +.input-prefix { + padding: 0 0 0 14px; + color: var(--ink-3); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + flex-shrink: 0; +} + +.input-wrap input { + flex: 1; + background: transparent; + border: none; + outline: none; + padding: 0 14px 0 6px; + height: 100%; + font-family: var(--font-mono); + font-size: 14px; + color: var(--ink); + font-feature-settings: "tnum"; +} + +.input-wrap input::placeholder { + color: var(--ink-3); + opacity: 0.7; +} + +.btn-primary { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--accent); + color: white; + border: none; + height: 40px; + padding: 0 18px; + border-radius: 10px; + font-weight: 600; + font-size: 13px; + font-family: var(--font-sans); + cursor: pointer; + box-shadow: 0 1px 0 color-mix(in oklab, var(--accent) 50%, black), 0 1px 3px color-mix(in oklab, var(--accent) 40%, transparent); + transition: transform 80ms ease, box-shadow 150ms ease, background 150ms ease; +} + +.btn-primary:hover { + background: color-mix(in oklab, var(--accent) 92%, black); +} + +.btn-primary:active { + transform: translateY(1px); +} + +@media (max-width: 768px) { + .add-bar { + flex-direction: column; + align-items: stretch; + } + + .add-bar-controls { + flex-wrap: wrap; + } + + .input-wrap { + flex: 1; + width: auto; + } } /* ── Inputs ───────────────────────────────────── */ @@ -556,20 +755,6 @@ a:hover { color: var(--text-muted); } -@media (max-width: 768px) { - .card-section { - padding: 16px; - } - - .card-section-form { - flex-direction: column; - } - - .card-section-form .input { - width: 100%; - } -} - /* ── Refresh Icons ────────────────────────────── */ .refresh-icon { @@ -601,13 +786,10 @@ a:hover { /* ── Responsive ───────────────────────────────── */ -@media (max-width: 768px) { +@media (max-width: 880px) { .container { - padding: 16px 12px 40px; - } - - .page-title { - font-size: 22px; + padding: 18px 16px 40px; + gap: 16px; } table { @@ -615,25 +797,17 @@ a:hover { } th, td { - padding: 10px 8px; + padding: 0 10px; } - th { - font-size: 10px; - } - - .mobile-hide { - display: none; - } - - thead { - top: var(--topbar-height); - } + .col-rank { width: 40px; } + .col-actions { width: 40px; } + .col-predicted { display: none; } } -@media (max-width: 480px) { - th, td { - padding: 8px 6px; +@media (max-width: 768px) { + .mobile-hide { + display: none; } } @@ -642,13 +816,27 @@ a:hover { .delta-pill { display: inline-flex; align-items: center; - padding: 2px 8px; + gap: 4px; + padding: 3px 8px 3px 6px; border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 1; + white-space: nowrap; +} + +.delta-glyph { + font-size: 9px; + display: inline-block; + line-height: 1; + position: relative; + top: -0.5px; +} + +.delta-num { font-family: var(--font-mono); font-feature-settings: "tnum", "zero"; - font-size: 11px; - font-weight: 500; - margin-top: 4px; + letter-spacing: 0; } .delta-pill.up { @@ -664,6 +852,7 @@ a:hover { .delta-pill.flat { background: var(--paper-2); color: var(--ink-3); + border: 1px solid var(--line); } /* ── Table Header Hints ───────────────────────── */ @@ -684,9 +873,9 @@ a:hover { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; - margin-bottom: 16px; list-style: none; padding: 0; + margin: 0; } .kpi-tile { @@ -749,14 +938,62 @@ a:hover { } } -/* ── Table Toolbar + Pill Toggle ─────────────── */ +/* ── Table Card + Toolbar ─────────────────────── */ + +.table-card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.kicker { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.10em; + color: var(--ink-3); +} .table-toolbar { display: flex; - justify-content: flex-end; - margin-bottom: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid var(--line); } +.toolbar-actions { + display: flex; + gap: 6px; + align-items: center; +} + +/* ── Icon Buttons ─────────────────────────────── */ + +.icon-btn { + background: transparent; + border: 1px solid transparent; + width: 28px; + height: 28px; + border-radius: 8px; + display: grid; + place-items: center; + color: var(--ink-3); + cursor: pointer; + font-size: 12px; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.icon-btn:hover { + background: var(--hover); + color: var(--ink); + border-color: var(--line); +} + +/* ── Table Pill Toggle ────────────────────────── */ + .pill-toggle { display: inline-flex; align-items: center; @@ -808,12 +1045,29 @@ a:hover { /* ── Sparklines ───────────────────────────────── */ .sparkline { - display: inline-block; - vertical-align: middle; + display: block; line-height: 0; - margin-top: 4px; + flex-shrink: 0; } -body[data-sparklines="off"] .sparkline { +.spark { + display: block; + overflow: visible; +} + +body[data-sparklines="off"] .sparkline, +body[data-sparklines="off"] .spark { display: none; } + +/* ── Footnote ─────────────────────────────────── */ + +.footnote { + font-size: 11.5px; + color: var(--ink-3); + text-align: center; + max-width: 640px; + margin: 0 auto; + line-height: 1.6; + padding: 0 16px; +} diff --git a/public/js/players.js b/public/js/players.js index 9701eb1..c581361 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -5,9 +5,25 @@ let openPdgaNumber = null; // ── Delta-pill helper ───────────────────────────── function renderDeltaPill(value, extraClass) { if (value == null) return null; - const cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; - const text = value > 0 ? '+' + value : String(value); - return { text, cls }; + const cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; + const glyph = value > 0 ? '▲' : value < 0 ? '▼' : '–'; + const num = value > 0 ? '+' + value : String(value); + return { glyph, num, cls }; +} + +function applyDeltaPill(pillEl, value) { + if (!pillEl || value == null) return; + const pill = renderDeltaPill(value); + pillEl.className = 'delta-pill ' + pill.cls; + while (pillEl.firstChild) pillEl.removeChild(pillEl.firstChild); + const glyphSpan = document.createElement('span'); + glyphSpan.className = 'delta-glyph'; + glyphSpan.textContent = pill.glyph; + const numSpan = document.createElement('span'); + numSpan.className = 'delta-num'; + numSpan.textContent = pill.num; + pillEl.appendChild(glyphSpan); + pillEl.appendChild(numSpan); } function setupTooltipsAfterSwap() { @@ -120,8 +136,8 @@ async function clearCache() { } async function refreshPlayer(pdgaNumber) { - const icon = document.querySelector(`#row-${pdgaNumber} .rating .refresh-icon`); - icon.classList.add('spinning'); + const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`); + if (icon) icon.classList.add('spinning'); try { const response = await fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }); @@ -129,12 +145,12 @@ async function refreshPlayer(pdgaNumber) { if (data.success) { const row = document.getElementById(`row-${pdgaNumber}`); - const ratingCell = row.querySelector('.rating'); + const ratingCell = row.querySelector('.cell-rating'); const nameLink = row.querySelector('.player-name a'); - nameLink.textContent = data.player.name; + if (nameLink) nameLink.textContent = data.player.name; - const ratingValue = ratingCell.querySelector('.rating-value'); + const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null; if (ratingValue) { ratingValue.textContent = data.player.rating || 'N/A'; ratingValue.dataset.rating = data.player.rating || ''; @@ -150,24 +166,20 @@ async function refreshPlayer(pdgaNumber) { } } - const deltaMonthPill = ratingCell.querySelector('.delta-pill'); - if (deltaMonthPill && data.player.ratingChange != null) { - const pill = renderDeltaPill(data.player.ratingChange); - deltaMonthPill.textContent = pill.text; - deltaMonthPill.className = `delta-pill ${pill.cls}`; - } + const deltaMonthPill = ratingCell ? ratingCell.querySelector('.delta-pill') : null; + applyDeltaPill(deltaMonthPill, data.player.ratingChange); } } catch (error) { console.error('Error refreshing player:', error); alert('Failed to refresh player data'); } finally { - icon.classList.remove('spinning'); + if (icon) icon.classList.remove('spinning'); } } async function refreshRoundHistory(pdgaNumber) { - const icon = document.querySelector(`#predicted-${pdgaNumber} .refresh-icon`); - icon.classList.add('spinning'); + const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`); + if (icon) icon.classList.add('spinning'); try { const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' }); @@ -197,8 +209,8 @@ async function refreshRoundHistory(pdgaNumber) { } const row = document.getElementById(`row-${pdgaNumber}`); - const ratingCell = row.querySelector('.rating'); - const ratingValue = ratingCell.querySelector('.rating-value'); + const ratingCell = row.querySelector('.cell-rating'); + const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null; if (ratingValue && data.stdDev) { ratingValue.dataset.stddev = data.stdDev; @@ -244,7 +256,7 @@ async function refreshRoundHistory(pdgaNumber) { const fullMessage = errorDetails ? errorMessage + '\n\n' + errorDetails : errorMessage; alert(fullMessage); } finally { - icon.classList.remove('spinning'); + if (icon) icon.classList.remove('spinning'); } } @@ -307,7 +319,8 @@ function closeDebugModal(event) { document.getElementById('debug-modal').style.display = 'none'; } -async function searchAndAddPlayer() { +async function searchAndAddPlayer(event) { + if (event) event.preventDefault(); const input = document.getElementById('pdga-number-input'); const pdgaNumber = input.value.trim(); diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 1c4898b..c600e18 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -1,21 +1,27 @@ <% var body = ` - -
-

Add Yourself to Tracked Players

-
- -
-
+
@@ -53,19 +59,25 @@
-
- + +
+
+ TRACKED PLAYERS +
+ +
+
+
-
- -
-
+ + +

Unofficial PDGA rating tracker. Ratings scraped from pdga.com on each refresh.

`; %> <% var modals = ` @@ -100,4 +112,4 @@ initScript: 'setupTooltipsAfterSwap();', body: body, modals: modals -}) %> \ No newline at end of file +}) %> diff --git a/views/partials/delta-pill.ejs b/views/partials/delta-pill.ejs index 819e94e..4fb7cf9 100644 --- a/views/partials/delta-pill.ejs +++ b/views/partials/delta-pill.ejs @@ -4,7 +4,8 @@ extraClass {string} — optional additional CSS class (e.g. 'delta-predicted-pill') */%> <% if (typeof value !== 'undefined' && value != null) { - const _cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; - const _text = value > 0 ? '+' + value : value.toString(); - const _xtra = (typeof extraClass !== 'undefined' && extraClass) ? ' ' + extraClass : ''; -%><%= _text %><% } %> + const _cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; + const _glyph = value > 0 ? '▲' : value < 0 ? '▼' : '–'; + const _num = value > 0 ? '+' + value : value.toString(); + const _xtra = (typeof extraClass !== 'undefined' && extraClass) ? ' ' + extraClass : ''; +%><%= _glyph %><%= _num %><% } %> diff --git a/views/partials/layout.ejs b/views/partials/layout.ejs index 78edd66..c759746 100644 --- a/views/partials/layout.ejs +++ b/views/partials/layout.ejs @@ -7,7 +7,7 @@ - + <% if (typeof cssFiles !== 'undefined') { %> diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index e8df398..0ebbacd 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -21,7 +21,7 @@ function renderSparkline(values) { var last = pts[pts.length - 1]; var areaPath = linePath + ' L ' + last.x + ' ' + h + ' L 0 ' + h + ' Z'; - return '' + + return '

No ratings found.

+

No players tracked yet.

<% } else { %> - - - - - + + + + + @@ -46,41 +46,49 @@ function renderSparkline(values) { const sparklineSvg = renderSparkline(player.monthlyHistory || []); %> - - - - + - +
RankPlayer NamePDGA #RatingPredictednext official update#PlayerRating+ Δ since last updatePredicted+ gap from today
<%= index + 1 %> - <%= player.name %> -
PDGA #<%= player.pdgaNumber %>
+
+ <%= index + 1 %> #<%= player.pdgaNumber %> -
- <%- player.rating || 'Click refresh' %> - +
+
+ <%= player.name %> + #<%= player.pdgaNumber %>
- <%- include('delta-pill', { value: player.ratingChange }) %> - <% if (sparklineSvg) { %> - <%- sparklineSvg %> +
+ <% if (player.rating) { %> +
+ <%= player.rating %> + <%- include('delta-pill', { value: player.ratingChange }) %> + <% if (sparklineSvg) { %><%- sparklineSvg %><% } %> +
+ <% } else { %> + Click to load <% } %>
-
- <%= player.predictedRating || 'N/A' %> - - +
+ <% if (player.predictedRating) { %> +
+ <%= player.predictedRating %> + <%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %>
- <%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %> + <% } else { %> + + <% } %>
+ + +
-
-
- Rating History for <%= player.name %> - -
-
Click to load rating history...
-- 2.52.0 From 7af9d8d69edfd0abeca3952c25abdef98d4dd159 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:16:09 +0200 Subject: [PATCH 18/27] fix: null-safe icon selectors after table restructure (#4) --- public/js/players.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/public/js/players.js b/public/js/players.js index c581361..1eaa5db 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -261,8 +261,8 @@ async function refreshRoundHistory(pdgaNumber) { } async function refreshRatingHistory(pdgaNumber) { - const icon = document.querySelector(`#history-${pdgaNumber} .chart-title .refresh-icon`); - icon.classList.add('spinning'); + // No dedicated icon in the expanded row; spinner state not needed here + const icon = null; try { const response = await fetch(`/api/refresh-rating-history/${pdgaNumber}`, { method: 'POST' }); @@ -277,8 +277,6 @@ async function refreshRatingHistory(pdgaNumber) { } catch (error) { console.error('Error refreshing rating history:', error); alert('Failed to refresh rating history'); - } finally { - icon.classList.remove('spinning'); } } @@ -329,10 +327,9 @@ async function searchAndAddPlayer(event) { return; } - const button = document.querySelector('.btn-add'); - const originalText = button.textContent; - button.disabled = true; - button.textContent = 'Searching...'; + const button = document.querySelector('.add-bar button[type="submit"]'); + const originalText = button ? button.textContent : ''; + if (button) { button.disabled = true; button.textContent = 'Searching...'; } try { const response = await fetch(`/api/search-player/${pdgaNumber}`); @@ -355,8 +352,7 @@ async function searchAndAddPlayer(event) { console.error('Error searching for player:', error); showErrorModal('Failed to search for player. Please try again.'); } finally { - button.disabled = false; - button.textContent = originalText; + if (button) { button.disabled = false; button.textContent = originalText; } } } -- 2.52.0 From 7fb8cab5e257fc47146519f4c70c11024b020719 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:16:51 +0200 Subject: [PATCH 19/27] feat: add 'View calculation details' link to expanded row (#4) --- views/partials/player-history.ejs | 1 + 1 file changed, 1 insertion(+) diff --git a/views/partials/player-history.ejs b/views/partials/player-history.ejs index fdbda08..9f6b61f 100644 --- a/views/partials/player-history.ejs +++ b/views/partials/player-history.ejs @@ -30,6 +30,7 @@ const chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber; + <% } %> <% if (history && history.length > 0) { %> -- 2.52.0 From 259a3fadf189be14b0236346a0c2c8c5e32d6cf7 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:17:09 +0200 Subject: [PATCH 20/27] chore: remove dead .refresh-section CSS (#4) --- public/css/players.css | 5 ----- 1 file changed, 5 deletions(-) diff --git a/public/css/players.css b/public/css/players.css index 5a68d0d..7e862e3 100644 --- a/public/css/players.css +++ b/public/css/players.css @@ -50,11 +50,6 @@ font-variant-numeric: tabular-nums; } -.refresh-section { - display: inline-flex; - align-items: center; -} - .predicted-value { position: relative; display: inline-block; -- 2.52.0 From 88df98f26978bc06d78fd00a13a55d02d398ce24 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:23:53 +0200 Subject: [PATCH 21/27] fix: place history chart in right grid column of expanded row (#7) The dl + button + chart were 3 direct children of .player-detail (a 2-column grid). Auto-placement put the button in the right column, forcing the chart to wrap to a second row in the left column. Wrap dl + button in .player-detail-left so the chart occupies col 2. --- views/partials/player-history.ejs | 56 ++++++++++++++++--------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/views/partials/player-history.ejs b/views/partials/player-history.ejs index 9f6b61f..3e082aa 100644 --- a/views/partials/player-history.ejs +++ b/views/partials/player-history.ejs @@ -4,33 +4,35 @@ const chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber; %>
<% if (hasPlayer) { %> -
-
-
Current rating
-
<%= player.rating ?? '—' %>
-
-
-
Last month
-
<%= player.lastMonthRating ?? '—' %>
-
-
-
Change vs last month
-
- <% if (player.ratingChange != null) { %><%- include('delta-pill', { value: player.ratingChange }) %><% } else { %>—<% } %> -
-
-
-
Predicted next update
-
<%= player.predictedRating ?? '—' %>
-
-
-
Gap to predicted
-
- <% if (player.deltaPredicted != null) { %><%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %><% } else { %>—<% } %> -
-
-
- +
+
+
+
Current rating
+
<%= player.rating ?? '—' %>
+
+
+
Last month
+
<%= player.lastMonthRating ?? '—' %>
+
+
+
Change vs last month
+
+ <% if (player.ratingChange != null) { %><%- include('delta-pill', { value: player.ratingChange }) %><% } else { %>—<% } %> +
+
+
+
Predicted next update
+
<%= player.predictedRating ?? '—' %>
+
+
+
Gap to predicted
+
+ <% if (player.deltaPredicted != null) { %><%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %><% } else { %>—<% } %> +
+
+
+ +
<% } %> <% if (history && history.length > 0) { %> -- 2.52.0 From 9feb5c2c43d62bbe79b38ab360b13af69c329799 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:27:08 +0200 Subject: [PATCH 22/27] =?UTF-8?q?fix:=20remove=20sticky=20thead=20?= =?UTF-8?q?=E2=80=94=20overlapped=20table=20toolbar=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thead had position: sticky; top: var(--topbar-height), pinning it to 64px from the viewport. Inside the new .table-card with a toolbar above, this pulled the header up out of its natural flow and overlapped the toolbar and first data row. Let thead flow normally — design doesn't require sticky behavior. --- public/css/shared.css | 6 ------ 1 file changed, 6 deletions(-) diff --git a/public/css/shared.css b/public/css/shared.css index 204cc57..cd3a4b5 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -318,12 +318,6 @@ table { font-size: 14px; } -thead { - position: sticky; - top: var(--topbar-height); - z-index: 10; -} - th { padding: 0 16px; height: 48px; -- 2.52.0 From 96ae7d7dac063dd78aab9b8bfbc88b46673c9ec2 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:33:05 +0200 Subject: [PATCH 23/27] fix CSS for rating row --- public/css/shared.css | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/public/css/shared.css b/public/css/shared.css index cd3a4b5..155237a 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -392,9 +392,8 @@ tr:last-child td { .rating-stack { display: flex; - flex-direction: column; + align-items: center; gap: 10px; - align-items: flex-start; } .rating-big { @@ -414,9 +413,8 @@ tr:last-child td { /* Predicted cell stack */ .pred-stack { display: flex; - flex-direction: column; + align-items: center; gap: 10px; - align-items: flex-start; } .pred-num { -- 2.52.0 From 4b145094bf6a2d3c5459003172ca8a113e382230 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:51:17 +0200 Subject: [PATCH 24/27] render flat delta pill for null values per design spec --- public/js/players.js | 10 +++++----- views/partials/delta-pill.ejs | 15 ++++++++------- views/partials/player-history.ejs | 8 ++------ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/public/js/players.js b/public/js/players.js index 1eaa5db..a240457 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -4,15 +4,15 @@ let openPdgaNumber = null; // ── Delta-pill helper ───────────────────────────── function renderDeltaPill(value, extraClass) { - if (value == null) return null; - const cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; - const glyph = value > 0 ? '▲' : value < 0 ? '▼' : '–'; - const num = value > 0 ? '+' + value : String(value); + const isNull = (value == null); + const cls = isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; + const glyph = (isNull || value === 0) ? '–' : value > 0 ? '▲' : '▼'; + const num = isNull ? '—' : value > 0 ? '+' + value : String(value); return { glyph, num, cls }; } function applyDeltaPill(pillEl, value) { - if (!pillEl || value == null) return; + if (!pillEl) return; const pill = renderDeltaPill(value); pillEl.className = 'delta-pill ' + pill.cls; while (pillEl.firstChild) pillEl.removeChild(pillEl.firstChild); diff --git a/views/partials/delta-pill.ejs b/views/partials/delta-pill.ejs index 4fb7cf9..0b1c367 100644 --- a/views/partials/delta-pill.ejs +++ b/views/partials/delta-pill.ejs @@ -1,11 +1,12 @@ <%/* delta-pill.ejs — renders a Δ-pill span. Locals: - value {number|null} — the delta value + value {number|null} — the delta value (null/undefined → flat pill with '—') extraClass {string} — optional additional CSS class (e.g. 'delta-predicted-pill') */%> -<% if (typeof value !== 'undefined' && value != null) { - const _cls = value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; - const _glyph = value > 0 ? '▲' : value < 0 ? '▼' : '–'; - const _num = value > 0 ? '+' + value : value.toString(); - const _xtra = (typeof extraClass !== 'undefined' && extraClass) ? ' ' + extraClass : ''; -%><%= _glyph %><%= _num %><% } %> +<% +const _isNull = (typeof value === 'undefined' || value == null); +const _cls = _isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; +const _glyph = (_isNull || value === 0) ? '–' : value > 0 ? '▲' : '▼'; +const _num = _isNull ? '—' : value > 0 ? '+' + value : value.toString(); +const _xtra = (typeof extraClass !== 'undefined' && extraClass) ? ' ' + extraClass : ''; +%><%= _glyph %><%= _num %> diff --git a/views/partials/player-history.ejs b/views/partials/player-history.ejs index 3e082aa..29af37b 100644 --- a/views/partials/player-history.ejs +++ b/views/partials/player-history.ejs @@ -16,9 +16,7 @@ const chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber;
Change vs last month
-
- <% if (player.ratingChange != null) { %><%- include('delta-pill', { value: player.ratingChange }) %><% } else { %>—<% } %> -
+
<%- include('delta-pill', { value: player.ratingChange }) %>
Predicted next update
@@ -26,9 +24,7 @@ const chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber;
Gap to predicted
-
- <% if (player.deltaPredicted != null) { %><%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %><% } else { %>—<% } %> -
+
<%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %>
-- 2.52.0 From a7562e9b47c1f69bfa9d296dac8279bcd9322cc4 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 15:58:48 +0200 Subject: [PATCH 25/27] fallback to monthlyHistory for lastMonthRating --- src/services/player-service.js | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/services/player-service.js b/src/services/player-service.js index f73f7f8..eb5e4dd 100644 --- a/src/services/player-service.js +++ b/src/services/player-service.js @@ -4,6 +4,28 @@ const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-htt const { calculatePredictedRating } = require('./rating-calculator'); const logger = require('../logger'); +// Derives previous-month rating and the delta to it. Prefers PDGA's reported +// rating_change (canonical), falls back to our own monthly snapshots when +// rating_change is missing — common for players whose latest scrape failed. +function deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory) { + if (rating != null && rawRatingChange != null) { + return { lastMonthRating: rating - rawRatingChange, ratingChange: rawRatingChange }; + } + if (rating != null && monthlyHistory && monthlyHistory.length >= 1) { + // The "last month" snapshot depends on whether current_rating is already in + // history. If equal, current is the most recent entry — last month is the one + // before it. If not, current is newer than history — the latest entry IS last month. + const lastIdx = monthlyHistory.length - 1; + const lastMonth = (monthlyHistory[lastIdx] === rating) + ? (monthlyHistory.length >= 2 ? monthlyHistory[lastIdx - 1] : null) + : monthlyHistory[lastIdx]; + if (lastMonth != null) { + return { lastMonthRating: lastMonth, ratingChange: rating - lastMonth }; + } + } + return { lastMonthRating: null, ratingChange: rawRatingChange }; +} + async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } = {}) { try { const cachedPlayer = await getPlayerFromDB(pdgaNumber); @@ -19,7 +41,7 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } } const rating = cachedPlayer.current_rating; - const ratingChange = cachedPlayer.rating_change; + const rawRatingChange = cachedPlayer.rating_change; const resolvedPredicted = predictedRating > 0 ? predictedRating : null; const resolvedStdDev = stdDev > 0 ? stdDev : null; @@ -28,6 +50,8 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } ? await getMonthlyHistory(cachedPlayer.pdga_number) : []; + const { lastMonthRating, ratingChange } = deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory); + return { pdgaNumber: cachedPlayer.pdga_number, name: cachedPlayer.name, @@ -35,8 +59,7 @@ async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } ratingChange, predictedRating: resolvedPredicted, stdDev: resolvedStdDev, - // previous month's official rating (null when either value is missing) - lastMonthRating: (rating != null && ratingChange != null) ? rating - ratingChange : null, + lastMonthRating, // gap between next predicted update and current rating (null when either is missing) deltaPredicted: (resolvedPredicted != null && rating != null) ? resolvedPredicted - rating : null, monthlyHistory @@ -166,6 +189,10 @@ async function getAllRatingsFromDB(progressCallback = null) { if (playerData) { playerData.monthlyHistory = monthlyHistoryMap.get(pdgaNumber) ?? []; + // Re-derive now that history is attached — bulk path skipped includeMonthlyHistory + const derived = deriveMonthlyDeltas(playerData.rating, player.rating_change, playerData.monthlyHistory); + playerData.lastMonthRating = derived.lastMonthRating; + playerData.ratingChange = derived.ratingChange; ratings.push(playerData); } -- 2.52.0 From b6c674e4c78f4146ab854ef217e47b83327c0dbc Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 16:04:15 +0200 Subject: [PATCH 26/27] wire refresh button to update both rating and prediction --- public/js/players.js | 53 ++++++++++---------------------- views/partials/ratings-table.ejs | 2 +- 2 files changed, 18 insertions(+), 37 deletions(-) diff --git a/public/js/players.js b/public/js/players.js index a240457..2aa59fd 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -135,10 +135,23 @@ async function clearCache() { } } -async function refreshPlayer(pdgaNumber) { +// Refreshes both the current rating and the prediction in one click. +// The single refresh button in the actions cell is wired to this wrapper +// (design has only one icon; we used to have two separate refresh buttons). +async function refreshPlayerData(pdgaNumber) { const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`); if (icon) icon.classList.add('spinning'); + try { + await Promise.allSettled([ + refreshPlayer(pdgaNumber), + refreshRoundHistory(pdgaNumber) + ]); + } finally { + if (icon) icon.classList.remove('spinning'); + } +} +async function refreshPlayer(pdgaNumber) { try { const response = await fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }); const data = await response.json(); @@ -172,15 +185,10 @@ async function refreshPlayer(pdgaNumber) { } catch (error) { console.error('Error refreshing player:', error); alert('Failed to refresh player data'); - } finally { - if (icon) icon.classList.remove('spinning'); } } async function refreshRoundHistory(pdgaNumber) { - const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`); - if (icon) icon.classList.add('spinning'); - try { const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' }); const data = await response.json(); @@ -226,37 +234,10 @@ async function refreshRoundHistory(pdgaNumber) { } } } catch (error) { + // Rate-limited or scrape failure — common when refresh runs alongside the + // current-rating refresh. Log but don't alert; the user-facing surface is + // the spinner stopping (data may or may not have updated). console.error('Error refreshing round history:', error); - - let errorMessage = 'Failed to refresh prediction data'; - let errorDetails = ''; - - try { - const errorData = JSON.parse(error.message); - errorMessage = errorData.error || errorMessage; - - if (errorData.message) { - errorDetails = errorData.message; - } else { - errorDetails = errorData.details || ''; - if (errorData.suggestion) { - errorDetails += '\n\nSuggestion: ' + errorData.suggestion; - } - if (errorData.errorType) { - errorDetails += '\n\nError Type: ' + errorData.errorType; - } - if (errorData.timestamp) { - errorDetails += '\n\nTime: ' + new Date(errorData.timestamp).toLocaleString(); - } - } - } catch (parseError) { - errorDetails = error.message; - } - - const fullMessage = errorDetails ? errorMessage + '\n\n' + errorDetails : errorMessage; - alert(fullMessage); - } finally { - if (icon) icon.classList.remove('spinning'); } } diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index 0ebbacd..5fc0783 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -79,7 +79,7 @@ function renderSparkline(values) {
-