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).
This commit is contained in:
Samuel Enocsson
2026-05-21 12:37:31 +02:00
parent 6e05d3014d
commit 8c977d6624
7 changed files with 359 additions and 102 deletions
+13 -1
View File
@@ -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
};
+7 -4
View File
@@ -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
+20
View File
@@ -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();
+45
View File
@@ -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 };