From 8c977d662472afc4e4e74345d63cbbdfccf64504 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 21 May 2026 12:37:31 +0200 Subject: [PATCH] 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 %> +
+ + +
+
+