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 %> +
+ + +
+
+