From cc9d8eb4cdebf89cccfbb7abd4452cecbe4fb765 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 21:07:00 +0200 Subject: [PATCH] feat: mobile UI card layout for players and courses (#16) --- public/css/mobile.css | 608 ++++++++++++++++++++++++++++++ public/css/shared.css | 37 +- public/js/chart.js | 23 +- public/js/courses.js | 33 ++ public/js/players.js | 121 +++++- views/pages/courses.ejs | 6 + views/pages/index.ejs | 14 + views/partials/course-cards.ejs | 41 ++ views/partials/course-table.ejs | 1 + views/partials/layout.ejs | 3 +- views/partials/mobile-add-bar.ejs | 12 + views/partials/ratings-cards.ejs | 120 ++++++ views/partials/ratings-table.ejs | 7 +- views/partials/topbar.ejs | 37 ++ 14 files changed, 1007 insertions(+), 56 deletions(-) create mode 100644 public/css/mobile.css create mode 100644 views/partials/course-cards.ejs create mode 100644 views/partials/mobile-add-bar.ejs create mode 100644 views/partials/ratings-cards.ejs diff --git a/public/css/mobile.css b/public/css/mobile.css new file mode 100644 index 0000000..d5f9c4f --- /dev/null +++ b/public/css/mobile.css @@ -0,0 +1,608 @@ +/* ═══════════════════════════════════════════════════ + PDGA Ratings — Mobile Styles (≤ 880px) + All rules scoped inside @media (max-width: 880px) + unless marked "default hidden" (for elements that + must be display:none on desktop too). + ═══════════════════════════════════════════════════ */ + +/* ── Default-hidden mobile elements ──────────────── */ +/* Hidden on ALL viewports; mobile.css un-hides them */ + +.topbar__mobile { display: none; } +.mobile-list { display: none; } +.mobile-add-bar { display: none; } +.mobile-section-head { display: none; } +.m-tab-pill { display: none; } + +/* ═══════════════════════════════════════════════════ */ +@media (max-width: 880px) { + + /* ── Desktop elements → hide on mobile ─────────── */ + + .topbar__inner { display: none !important; } + .kpi-strip { display: none !important; } + .add-bar { display: none !important; } + .footnote { display: none !important; } + + /* Hide desktop table but keep .table-card as wrapper */ + .table-card > #ratings-table table, + #ratings-table table, + #courses-table table { + display: none; + } + + /* Hide search result info text on courses (mobile has section-head) */ + #search-results-info { display: none; } + + /* ── Container ──────────────────────────────────── */ + + .container { + padding: 10px 12px 80px; + gap: 12px; + } + + /* ── Topbar mobile ──────────────────────────────── */ + + .topbar__mobile { + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + } + + .topbar__mobile-row1 { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px 8px; + gap: 10px; + } + + .topbar__mobile-brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + } + + .topbar__mobile-mark { + width: 26px; + height: 26px; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 70%, black)); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; + } + + .topbar__mobile-mark svg { + width: 16px; + height: 16px; + } + + .topbar__mobile-brand-text { + display: flex; + flex-direction: column; + line-height: 1.1; + } + + .topbar__mobile-title { + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); + } + + .topbar__mobile-sub { + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-3); + } + + .topbar__mobile-refresh { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--paper); + color: var(--ink); + cursor: pointer; + font-size: 16px; + font-family: var(--font-sans); + transition: background 120ms ease, border-color 120ms ease; + flex-shrink: 0; + padding: 0; + } + + .topbar__mobile-refresh:hover:not(:disabled) { + background: var(--hover); + border-color: color-mix(in oklab, var(--line) 60%, var(--ink-3)); + } + + .topbar__mobile-refresh:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .topbar__mobile-refresh .topbar__refresh-spinner { + display: none; + width: 14px; + height: 14px; + border: 2px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: topbar-spin 0.7s linear infinite; + } + + .topbar__mobile-refresh.htmx-request .topbar__refresh-spinner { + display: inline-block; + } + + .topbar__mobile-refresh.htmx-request .topbar__refresh-icon { + display: none; + } + + .topbar__mobile-row2 { + padding: 0 16px 10px; + } + + .topbar__mobile-nav { + display: flex; + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 4px; + gap: 2px; + } + + .topbar__mobile-nav a { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + height: 28px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + color: var(--ink-2); + text-decoration: none; + transition: color 120ms ease, background 120ms ease; + } + + .topbar__mobile-nav a:hover { + color: var(--ink); + } + + .topbar__mobile-nav a.active { + background: var(--paper); + color: var(--ink); + box-shadow: var(--shadow-card); + } + + /* ── Mobile section head ────────────────────────── */ + + .mobile-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0 2px; + } + + /* pill-button for "Trend chart" toggle in mobile section head */ + .pill-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--line-2); + background: var(--paper-2); + color: var(--ink-2); + font-size: 11px; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease; + } + + .pill-button:hover { + background: var(--hover); + border-color: var(--line); + } + + .pill-button[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); + } + + /* ── Mobile list wrapper ────────────────────────── */ + + .mobile-list { + display: flex; + flex-direction: column; + gap: 10px; + padding-bottom: 90px; + } + + /* ── Player card ────────────────────────────────── */ + + .m-card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + box-shadow: var(--shadow-card); + cursor: pointer; + transition: background 120ms ease; + } + + .m-card:hover { + background: var(--hover); + } + + .m-card__head { + display: flex; + align-items: center; + gap: 10px; + } + + .m-rank-chip { + width: 24px; + height: 24px; + border-radius: 6px; + background: var(--paper-2); + border: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--ink-3); + flex-shrink: 0; + } + + .m-rank-chip--first { + background: var(--accent-soft); + color: var(--accent-text); + border-color: color-mix(in oklab, var(--accent) 25%, transparent); + } + + .m-card__name-stack { + display: flex; + flex-direction: column; + gap: 1px; + flex: 1; + min-width: 0; + } + + .m-player-name { + font-size: 14px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.005em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .m-pdga-num { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-3); + } + + .m-chevron { + color: var(--ink-3); + font-size: 14px; + flex-shrink: 0; + transition: transform 180ms ease; + line-height: 1; + } + + .m-card.is-open .m-chevron { + transform: rotate(180deg); + } + + .m-card__body { + display: grid; + grid-template-columns: 1fr auto; + gap: 10px; + align-items: center; + margin-top: 8px; + } + + .m-card__stats { + display: flex; + flex-direction: column; + gap: 6px; + } + + .m-stat-row { + display: flex; + align-items: center; + gap: 8px; + } + + .m-stat-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-3); + width: 62px; + flex-shrink: 0; + } + + .m-num { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + font-size: 18px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.02em; + line-height: 1; + } + + .m-num--predicted { + color: var(--ink-2); + } + + /* Override delta-pill size inside mobile cards */ + .m-card .delta-pill { + font-size: 11px; + padding: 2px 7px 2px 5px; + } + + .m-card__sparkline { + display: flex; + align-items: center; + justify-content: center; + grid-row: span 2; + } + + .m-chart-spark { + display: block; + overflow: visible; + } + + /* ── Player card expand panel ───────────────────── */ + + .m-card__expand { + display: none; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); + } + + .m-card.is-open .m-card__expand { + display: block; + } + + .m-chart { + width: 100%; + max-width: 480px; + } + + .m-detail-grid { + display: grid; + grid-template-columns: 1fr; + margin: 10px 0 0; + padding: 0; + list-style: none; + } + + .m-detail-grid > div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 6px 0; + border-bottom: 1px dashed var(--line); + } + + .m-detail-grid > div:last-child { + border-bottom: none; + } + + .m-detail-grid dt { + color: var(--ink-2); + font-size: 12px; + font-weight: 400; + flex-shrink: 0; + } + + .m-detail-grid dd { + color: var(--ink); + font-size: 12px; + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + margin: 0; + text-align: right; + } + + /* ── Courses mobile tab pill ────────────────────── */ + + .m-tab-pill { + display: flex; + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 4px; + gap: 2px; + margin-bottom: 4px; + } + + .m-tab-pill__btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + height: 30px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + font-family: var(--font-sans); + color: var(--ink-2); + background: transparent; + border: none; + cursor: pointer; + transition: color 120ms ease, background 120ms ease; + } + + .m-tab-pill__btn--active { + background: var(--paper); + color: var(--ink); + box-shadow: var(--shadow-card); + } + + .m-tab-pill__btn--disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + + /* ── Course card ────────────────────────────────── */ + + .m-course-card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + box-shadow: var(--shadow-card); + cursor: pointer; + transition: background 120ms ease; + } + + .m-course-card:hover { + background: var(--hover); + } + + .m-course-card__head { + display: flex; + align-items: center; + gap: 10px; + } + + .m-course-card__name-stack { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + } + + .m-course-name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .m-course-name { + font-size: 14px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.005em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .m-layouts-pill { + font-family: var(--font-mono); + font-size: 11px; + padding: 2px 7px; + border-radius: 999px; + background: var(--paper-2); + border: 1px solid var(--line-2); + color: var(--ink-3); + white-space: nowrap; + flex-shrink: 0; + } + + .m-course-card__meta { + font-size: 12px; + color: var(--ink-3); + margin-top: 2px; + } + + .m-course-card__expand { + display: none; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); + } + + .m-course-card.is-open .m-course-card__expand { + display: block; + } + + .m-course-card.is-open .m-chevron { + transform: rotate(180deg); + } + + /* ── Sticky mobile add-bar ──────────────────────── */ + + .mobile-add-bar { + display: flex; + gap: 8px; + padding: 10px 12px calc(10px + env(safe-area-inset-bottom)) 12px; + background: color-mix(in oklab, var(--paper) 88%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-top: 1px solid var(--line); + position: sticky; + bottom: 0; + z-index: 10; + /* Negative margin to break out of container padding */ + margin-left: -12px; + margin-right: -12px; + margin-bottom: -80px; + } + + .mobile-add-bar input { + flex: 1; + height: 38px; + background: var(--paper-2); + border: 1px solid var(--line-2); + border-radius: 10px; + padding: 0 14px; + font-family: var(--font-mono); + font-size: 14px; + color: var(--ink); + font-feature-settings: "tnum"; + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; + } + + .mobile-add-bar input::placeholder { + color: var(--ink-3); + opacity: 0.7; + } + + .mobile-add-bar input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 14%, transparent); + } + + .mobile-add-bar .btn-primary { + height: 38px; + padding: 0 18px; + flex-shrink: 0; + } + + /* ── Table-card: remove overflow:hidden on mobile ─ */ + /* so sticky add-bar can extend to bottom */ + .table-card { + overflow: visible; + } + +} /* end @media (max-width: 880px) */ diff --git a/public/css/shared.css b/public/css/shared.css index 155237a..7e43319 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -708,21 +708,7 @@ a:hover { 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; - } -} +/* add-bar responsive handled by mobile.css (≤880px hides it entirely) */ /* ── Inputs ───────────────────────────────────── */ @@ -778,26 +764,9 @@ a:hover { /* ── Responsive ───────────────────────────────── */ +/* Responsive table/container tweaks handled by mobile.css (≤880px) */ + @media (max-width: 880px) { - .container { - padding: 18px 16px 40px; - gap: 16px; - } - - table { - font-size: 13px; - } - - th, td { - padding: 0 10px; - } - - .col-rank { width: 40px; } - .col-actions { width: 40px; } - .col-predicted { display: none; } -} - -@media (max-width: 768px) { .mobile-hide { display: none; } diff --git a/public/js/chart.js b/public/js/chart.js index 970d99a..fbb01bb 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -1,4 +1,5 @@ -function createRatingChart(container, history) { +function createRatingChart(container, history, opts) { + opts = opts || {}; if (!history || history.length === 0) { container.textContent = ''; var empty = document.createElement('div'); @@ -8,8 +9,13 @@ function createRatingChart(container, history) { return; } - var W = 880, H = 240; - var pad = { left: 44, right: 16, top: 20, bottom: 32 }; + var W = opts.w || 880; + var H = opts.h || 240; + var pad = opts.padding || { left: 44, right: 16, top: 20, bottom: 32 }; + var tickCount = opts.tickCount !== undefined ? opts.tickCount : 4; + var xLabelCount = opts.xLabelCount !== undefined ? opts.xLabelCount : 5; + var dotR = opts.dotR !== undefined ? opts.dotR : 3; + var lastDotR = opts.lastDotR !== undefined ? opts.lastDotR : 4; var chartW = W - pad.left - pad.right; var chartH = H - pad.top - pad.bottom; @@ -61,8 +67,7 @@ function createRatingChart(container, history) { 'aria-hidden': 'true' }); - // Grid lines + y-axis labels (4 ticks) - var tickCount = 4; + // Grid lines + y-axis labels for (var i = 0; i <= tickCount; i++) { var gy = pad.top + (i / tickCount) * chartH; var gr = Math.round(maxR - (i / tickCount) * range); @@ -96,18 +101,18 @@ 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: 'var(--paper)', 'stroke-width': '2' + r: String(lastDotR), fill: 'var(--accent)', stroke: 'var(--paper)', 'stroke-width': '2' })); } else { svg.appendChild(el('circle', { cx: p.x.toFixed(1), cy: p.y.toFixed(1), - r: '3', fill: 'var(--accent)' + r: String(dotR), fill: 'var(--accent)' })); } }); - // X-axis labels (5 evenly spaced) - var labelCount = Math.min(5, history.length); + // X-axis labels (evenly spaced) + var labelCount = Math.min(xLabelCount, history.length); var labelIndices = []; if (labelCount <= 1) { labelIndices.push(0); diff --git a/public/js/courses.js b/public/js/courses.js index ef557eb..566d25b 100644 --- a/public/js/courses.js +++ b/public/js/courses.js @@ -30,6 +30,39 @@ function toggleCourseLayouts(courseId) { layoutsContainer.dataset.loaded = 'true'; } +// ── Mobile course card toggle ────────────────────── +var openMobileCourseId = null; + +function toggleMobileCourseLayouts(courseId) { + var card = document.getElementById('m-course-' + courseId); + if (!card) return; + + var isOpen = card.classList.contains('is-open'); + + // Close previously open card + if (openMobileCourseId !== null && openMobileCourseId !== courseId) { + var prevCard = document.getElementById('m-course-' + openMobileCourseId); + if (prevCard) prevCard.classList.remove('is-open'); + openMobileCourseId = null; + } + + if (isOpen) { + card.classList.remove('is-open'); + openMobileCourseId = null; + return; + } + + card.classList.add('is-open'); + openMobileCourseId = courseId; + + // Lazy-load layouts on first expand + var container = document.getElementById('m-layouts-container-' + courseId); + if (container && container.dataset.loaded !== 'true') { + htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: '#m-layouts-container-' + courseId, swap: 'innerHTML' }); + container.dataset.loaded = 'true'; + } +} + async function scrapeCourses() { const btn = document.getElementById('scrape-courses-btn'); btn.disabled = true; diff --git a/public/js/players.js b/public/js/players.js index ee5797a..dff8c10 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -31,8 +31,21 @@ function initChartsIn(rootEl) { if (container.dataset.charted === 'true') return; if (!container.dataset.history) return; try { - const history = JSON.parse(container.dataset.history); - createRatingChart(container, history); + var history = JSON.parse(container.dataset.history); + var isMobile = container.dataset.variant === 'mobile'; + if (isMobile) { + createRatingChart(container, history, { + w: 360, + h: 160, + padding: { left: 36, right: 12, top: 14, bottom: 24 }, + tickCount: 3, + xLabelCount: 3, + dotR: 2, + lastDotR: 3 + }); + } else { + createRatingChart(container, history); + } container.dataset.charted = 'true'; } catch (e) { console.error('Error rendering chart:', e); @@ -516,18 +529,34 @@ function closeAddPlayerModal(event) { // ── Sparkline toggle ─────────────────────────────── document.addEventListener('DOMContentLoaded', function() { - const btn = document.getElementById('trendchart-toggle'); - if (!btn) return; + var SPARKLINE_KEY = 'ratingtracker.sparklines'; - const state = localStorage.getItem('ratingtracker.sparklines') || 'on'; + function syncSparklineButtons(state) { + var btns = document.querySelectorAll('#trendchart-toggle, #trendchart-toggle-mobile'); + btns.forEach(function(b) { + b.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false'); + }); + } + + var state = localStorage.getItem(SPARKLINE_KEY) || 'on'; document.body.dataset.sparklines = state; - btn.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false'); + syncSparklineButtons(state); - btn.addEventListener('click', function() { - const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on'; + document.body.addEventListener('click', function(e) { + var target = e.target.closest('#trendchart-toggle, #trendchart-toggle-mobile'); + if (!target) return; + 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); + localStorage.setItem(SPARKLINE_KEY, next); + syncSparklineButtons(next); + }); + + // Re-sync after HTMX table swap (mobile button is inside the swapped partial) + document.body.addEventListener('htmx:afterSwap', function(event) { + var target = event.detail.target; + if (target.id === 'ratings-table') { + syncSparklineButtons(document.body.dataset.sparklines || 'on'); + } }); }); @@ -751,3 +780,75 @@ async function refreshHistoryThenCalculate(pdgaNumber) { _targetResultMsg(result, 'error', 'Network error during refresh. Please try again.'); } } + +// ── Mobile player card toggle ────────────────────── +var openMobilePdgaNumber = null; + +function toggleMobilePlayerCard(pdgaNumber) { + var card = document.getElementById('m-card-' + pdgaNumber); + if (!card) return; + + var isOpen = card.classList.contains('is-open'); + + // Close previously open card + if (openMobilePdgaNumber !== null && openMobilePdgaNumber !== pdgaNumber) { + var prevCard = document.getElementById('m-card-' + openMobilePdgaNumber); + if (prevCard) prevCard.classList.remove('is-open'); + openMobilePdgaNumber = null; + } + + if (isOpen) { + card.classList.remove('is-open'); + openMobilePdgaNumber = null; + return; + } + + card.classList.add('is-open'); + openMobilePdgaNumber = pdgaNumber; + + // Init charts inside the expand panel + var expand = card.querySelector('.m-card__expand'); + if (expand) { + initChartsIn(expand); + } +} + +// ── Mobile add player ────────────────────────────── +async function searchAndAddPlayerMobile(event) { + if (event) event.preventDefault(); + var input = document.getElementById('pdga-number-input-mobile'); + var pdgaNumber = input ? input.value.trim() : ''; + + if (!pdgaNumber) { + alert('Please enter a PDGA number'); + return; + } + + var button = event && event.target ? event.target.querySelector('button[type="submit"]') : null; + if (button) { button.disabled = true; button.textContent = 'Searching...'; } + + try { + var response = await fetch('/api/search-player/' + pdgaNumber); + var data = await response.json(); + + if (!response.ok) { + showErrorModal(data.error || 'Player not found'); + return; + } + + if (data.alreadyExists) { + showInfoModal(data.player.name + ' is already being tracked!'); + return; + } + + pendingPlayerData = data.player; + showConfirmationModal(data.player); + + } catch (error) { + console.error('Error searching for player:', error); + showErrorModal('Failed to search for player. Please try again.'); + } finally { + if (button) { button.disabled = false; button.textContent = 'Add'; } + if (input) input.value = ''; + } +} diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index 63f3a99..9f93f00 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -19,6 +19,12 @@ + +
+ + +
+
`; %> diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 93d7791..a3d994f 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -78,6 +78,20 @@

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

+ + +
+ + +
`; %> <% var modals = ` diff --git a/views/partials/course-cards.ejs b/views/partials/course-cards.ejs new file mode 100644 index 0000000..ab372f9 --- /dev/null +++ b/views/partials/course-cards.ejs @@ -0,0 +1,41 @@ +<% +var _query = (typeof query !== 'undefined') ? query : null; +var _total = (typeof total !== 'undefined') ? total : courses.length; +%> + +
+ Showing <%= courses.length %> of <%= _total %> + View all → +
+ +<% if (courses.length === 0) { %> +

No courses found.

+<% } else { %> +
+ <% courses.forEach(function(course) { + var lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + var layoutCount = course.layoutCount || 0; + %> +
+
+
+
+ <%= course.name %> + <% if (layoutCount > 0) { %> + <%= layoutCount %> layout<%= layoutCount !== 1 ? 's' : '' %> + <% } %> +
+
<%= course.city %> · <%= lastUpdated %>
+
+ +
+ +
+
+
Tap to load layouts...
+
+
+
+ <% }); %> +
+<% } %> diff --git a/views/partials/course-table.ejs b/views/partials/course-table.ejs index 93a26d3..a6e9899 100644 --- a/views/partials/course-table.ejs +++ b/views/partials/course-table.ejs @@ -43,4 +43,5 @@ <% }); %> + <%- include('course-cards', { courses: courses, query: locals.query, total: locals.total }) %> <% } %> diff --git a/views/partials/layout.ejs b/views/partials/layout.ejs index c759746..7a899dd 100644 --- a/views/partials/layout.ejs +++ b/views/partials/layout.ejs @@ -2,7 +2,7 @@ - + <%= title %> @@ -10,6 +10,7 @@ + <% if (typeof cssFiles !== 'undefined') { %> <% cssFiles.forEach(function(file) { %> diff --git a/views/partials/mobile-add-bar.ejs b/views/partials/mobile-add-bar.ejs new file mode 100644 index 0000000..2b9446a --- /dev/null +++ b/views/partials/mobile-add-bar.ejs @@ -0,0 +1,12 @@ +
+ + +
diff --git a/views/partials/ratings-cards.ejs b/views/partials/ratings-cards.ejs new file mode 100644 index 0000000..2bebfff --- /dev/null +++ b/views/partials/ratings-cards.ejs @@ -0,0 +1,120 @@ +<% +// Mobile sparkline helper — parametrised, used only in this partial +function renderSparkline(values, opts) { + opts = opts || {}; + var w = opts.w || 70; + var h = opts.h || 26; + if (!values || values.length < 2) return ''; + 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 ''; +} +%> + +
+ TRACKED PLAYERS · <%= ratings.length %> + +
+ +<% if (ratings.length === 0) { %> +

No players tracked yet.

+<% } else { %> +
+ <% ratings.forEach(function(player, index) { + var sparkSvg = renderSparkline(player.monthlyHistory || [], { w: 70, h: 26 }); + var isFirst = index === 0; + var rank = index + 1; + + var ratingIsNull = (player.rating == null); + var ratingCls = ratingIsNull ? 'flat' : (player.ratingChange > 0 ? 'up' : player.ratingChange < 0 ? 'down' : 'flat'); + var ratingGlyph = (ratingIsNull || player.ratingChange === 0) ? '–' : (player.ratingChange > 0 ? '▲' : '▼'); + var ratingNum = ratingIsNull ? '—' : (player.ratingChange > 0 ? '+' + player.ratingChange : String(player.ratingChange)); + + var predIsNull = (player.predictedRating == null); + var predCls = predIsNull ? 'flat' : (player.deltaPredicted > 0 ? 'up' : player.deltaPredicted < 0 ? 'down' : 'flat'); + var predGlyph = (predIsNull || player.deltaPredicted === 0) ? '–' : (player.deltaPredicted > 0 ? '▲' : '▼'); + var predNum = predIsNull ? '—' : (player.deltaPredicted > 0 ? '+' + player.deltaPredicted : String(player.deltaPredicted)); + %> +
+
+
<%= rank %>
+
+ <%= player.name %> + #<%= player.pdgaNumber %> +
+ +
+ +
+
+
+ RATING + <%= player.rating || '—' %> + <%= ratingGlyph %><%= ratingNum %> +
+
+ PREDICTED + <%= player.predictedRating || '—' %> + <%= predGlyph %><%= predNum %> +
+
+ <% if (sparkSvg) { %> +
<%- sparkSvg %>
+ <% } %> +
+ +
+ <% if (player.ratingHistory && player.ratingHistory.length > 0) { %> +
+
+ <% } %> +
+
+
Current rating
+
<%= player.rating || '—' %>
+
+
+
Last month
+
<%= player.lastMonthRating || '—' %>
+
+
+
Change vs last month
+
<%= ratingGlyph %><%= ratingNum %>
+
+
+
Predicted next update
+
<%= player.predictedRating || '—' %>
+
+
+
Gap to predicted
+
<%= predGlyph %><%= predNum %>
+
+
+
+
+ <% }); %> +
+<% } %> diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index 2d2493e..3d049ee 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -1,7 +1,9 @@ <% -function renderSparkline(values) { +function renderSparkline(values, opts) { + opts = opts || {}; + var w = opts.w || 96; + var h = opts.h || 28; 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; @@ -104,4 +106,5 @@ function renderSparkline(values) { <% }); %> + <%- include('ratings-cards', { ratings: ratings }) %> <% } %> diff --git a/views/partials/topbar.ejs b/views/partials/topbar.ejs index 81ed594..3d6d749 100644 --- a/views/partials/topbar.ejs +++ b/views/partials/topbar.ejs @@ -43,4 +43,41 @@ + +
+ +
+ +
+