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:
+225
-88
@@ -2,52 +2,75 @@
|
|||||||
PDGA Ratings — Shared Design System
|
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 <link> in layout.ejs for parallel loading */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Color system */
|
/* ── New design tokens ─────────────────────────── */
|
||||||
--surface-0: #f0f2f5;
|
--bg: oklch(0.985 0.005 250);
|
||||||
--surface-1: #ffffff;
|
--paper: #ffffff;
|
||||||
--surface-2: #f8f9fb;
|
--paper-2: oklch(0.975 0.006 250);
|
||||||
--surface-3: #eef0f4;
|
--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;
|
--accent: #4f5fd8;
|
||||||
--navy-800: #1e293b;
|
--accent-soft: color-mix(in oklab, var(--accent) 12%, white);
|
||||||
--navy-700: #334155;
|
--accent-text: color-mix(in oklab, var(--accent) 92%, black);
|
||||||
--navy-600: #475569;
|
|
||||||
|
|
||||||
--text-primary: #0f172a;
|
--up: oklch(0.55 0.15 150);
|
||||||
--text-secondary: #64748b;
|
--up-soft: oklch(0.94 0.04 150);
|
||||||
--text-muted: #94a3b8;
|
--down: oklch(0.58 0.18 25);
|
||||||
--text-inverse: #f8fafc;
|
--down-soft: oklch(0.95 0.04 25);
|
||||||
|
|
||||||
--accent: #3b82f6;
|
--radius: 14px;
|
||||||
--accent-hover: #2563eb;
|
--radius-sm: 10px;
|
||||||
--accent-subtle: rgba(59, 130, 246, 0.08);
|
|
||||||
--accent-border: rgba(59, 130, 246, 0.2);
|
|
||||||
|
|
||||||
--green: #10b981;
|
--shadow-card: 0 1px 0 oklch(0.92 0.01 260), 0 1px 2px oklch(0.85 0.01 260 / 0.30);
|
||||||
--green-subtle: rgba(16, 185, 129, 0.1);
|
--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);
|
||||||
--red: #ef4444;
|
|
||||||
--red-subtle: rgba(239, 68, 68, 0.1);
|
|
||||||
--amber: #f59e0b;
|
|
||||||
|
|
||||||
--border: #e2e8f0;
|
--font-sans: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
--border-light: #f1f5f9;
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
|
||||||
--radius-sm: 6px;
|
/* legacy token aliases — remove as components migrate */
|
||||||
--radius-md: 10px;
|
--surface-0: var(--bg);
|
||||||
--radius-lg: 14px;
|
--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;
|
--radius-xl: 20px;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
--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-md: var(--shadow-card);
|
||||||
--shadow-lg: 0 8px 30px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04);
|
--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);
|
--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);
|
--transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,81 +82,208 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-feature-settings: "ss01", "cv11";
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: var(--surface-0);
|
background: var(--bg);
|
||||||
color: var(--text-primary);
|
color: var(--ink);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── App Shell ────────────────────────────────── */
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-feature-settings: "tnum", "zero";
|
||||||
|
}
|
||||||
|
|
||||||
.app-header {
|
/* ── Topbar ───────────────────────────────────── */
|
||||||
background: var(--navy-900);
|
|
||||||
color: var(--text-inverse);
|
.topbar {
|
||||||
padding: 0 24px;
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 50;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
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 {
|
.topbar__inner {
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 56px;
|
gap: 16px;
|
||||||
|
padding: 14px 32px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo {
|
.topbar__brand {
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--text-inverse);
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo .logo-icon {
|
.topbar__brand-mark {
|
||||||
width: 28px;
|
width: 36px;
|
||||||
height: 28px;
|
height: 36px;
|
||||||
background: var(--accent);
|
border-radius: 10px;
|
||||||
border-radius: var(--radius-sm);
|
background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 70%, black));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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;
|
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;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav a {
|
.topbar__nav a {
|
||||||
padding: 6px 14px;
|
display: inline-flex;
|
||||||
color: var(--text-muted);
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-2);
|
||||||
text-decoration: none;
|
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;
|
font-weight: 500;
|
||||||
border-radius: var(--radius-sm);
|
color: var(--ink);
|
||||||
transition: color var(--transition), background var(--transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav a:hover {
|
.topbar__divider {
|
||||||
color: var(--text-inverse);
|
width: 1px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
height: 22px;
|
||||||
|
background: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav a.active {
|
.topbar__refresh {
|
||||||
color: var(--text-inverse);
|
display: inline-flex;
|
||||||
background: rgba(255, 255, 255, 0.12);
|
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 ────────────────────────────────── */
|
/* ── Container ────────────────────────────────── */
|
||||||
@@ -380,19 +530,6 @@ a:hover {
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-nav a {
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
table {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -410,7 +547,7 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
top: 48px;
|
top: 56px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -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 = {
|
module.exports = {
|
||||||
getPlayerFromDB,
|
getPlayerFromDB,
|
||||||
savePlayerToDB,
|
savePlayerToDB,
|
||||||
@@ -194,5 +205,6 @@ module.exports = {
|
|||||||
getLastRoundUpdateDate,
|
getLastRoundUpdateDate,
|
||||||
updateLastRoundUpdateDate,
|
updateLastRoundUpdateDate,
|
||||||
saveRoundHistoryToDB,
|
saveRoundHistoryToDB,
|
||||||
savePredictedRatingToDB
|
savePredictedRatingToDB,
|
||||||
|
getLastRefresh
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-4
@@ -1,12 +1,15 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const { getTopbarLocals } = require('../services/topbar-service');
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
res.render('index');
|
const topbar = await getTopbarLocals();
|
||||||
|
res.render('index', { activePage: 'players', ...topbar });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/courses', (req, res) => {
|
router.get('/courses', async (req, res) => {
|
||||||
res.render('courses');
|
const topbar = await getTopbarLocals();
|
||||||
|
res.render('courses', { activePage: 'courses', ...topbar });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep old URL working
|
// Keep old URL working
|
||||||
|
|||||||
@@ -6,9 +6,29 @@ const { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHis
|
|||||||
const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer');
|
const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer');
|
||||||
const { launchBrowser } = require('../scrapers/browser');
|
const { launchBrowser } = require('../scrapers/browser');
|
||||||
const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service');
|
const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service');
|
||||||
|
const { getTopbarLocals } = require('../services/topbar-service');
|
||||||
const { calculatePredictedRating } = require('../services/rating-calculator');
|
const { calculatePredictedRating } = require('../services/rating-calculator');
|
||||||
const logger = require('../logger');
|
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) => {
|
router.get('/partials/ratings-table', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const ratings = await getAllRatingsFromDB();
|
const ratings = await getAllRatingsFromDB();
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><%= title %></title>
|
<title><%= title %></title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300..700;1,300..700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<link rel="stylesheet" href="/css/shared.css">
|
<link rel="stylesheet" href="/css/shared.css">
|
||||||
<% if (typeof cssFiles !== 'undefined') { %>
|
<% if (typeof cssFiles !== 'undefined') { %>
|
||||||
@@ -14,15 +17,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="app-header">
|
<%- include('../partials/topbar', { activePage, lastRefresh, nextUpdate }) %>
|
||||||
<div class="header-inner">
|
|
||||||
<a href="/" class="app-logo">
|
|
||||||
<span class="logo-icon"><i class="fas fa-compact-disc"></i></span>
|
|
||||||
PDGA Ratings
|
|
||||||
</a>
|
|
||||||
<%- include('../partials/nav') %>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<%- body %>
|
<%- body %>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<header class="topbar" id="topbar">
|
||||||
|
<div class="topbar__inner">
|
||||||
|
<a href="/" class="topbar__brand">
|
||||||
|
<span class="topbar__brand-mark" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 17 L9 11 L13 15 L21 6" />
|
||||||
|
<path d="M14 6 L21 6 L21 13" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="topbar__brand-text">
|
||||||
|
<span class="topbar__brand-title">Rating Tracker</span>
|
||||||
|
<span class="topbar__brand-sub">Disc golf · unofficial</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="topbar__nav" aria-label="Primary">
|
||||||
|
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
|
||||||
|
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="topbar__meta">
|
||||||
|
<div class="topbar__meta-item">
|
||||||
|
<span class="topbar__meta-label">Next update</span>
|
||||||
|
<span class="topbar__meta-value"><%= nextUpdate %></span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar__meta-item">
|
||||||
|
<span class="topbar__meta-label">Last refresh</span>
|
||||||
|
<span class="topbar__meta-value"><%= lastRefresh %></span>
|
||||||
|
</div>
|
||||||
|
<span class="topbar__divider" aria-hidden="true"></span>
|
||||||
|
<button
|
||||||
|
class="topbar__refresh"
|
||||||
|
type="button"
|
||||||
|
hx-post="/api/refresh-all"
|
||||||
|
hx-target="#topbar"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-disabled-elt="this"
|
||||||
|
>
|
||||||
|
<span class="topbar__refresh-icon" aria-hidden="true">↻</span>
|
||||||
|
<span class="topbar__refresh-spinner" aria-hidden="true"></span>
|
||||||
|
<span class="topbar__refresh-label">Refresh all</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
Reference in New Issue
Block a user