From 4bbf6d97289397f794000fe01ebb89543532f34c Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Mon, 25 May 2026 09:39:44 +0200 Subject: [PATCH 1/6] feat: redesign Courses page with tabs + restore Tjing import (#8) - Restore src/scrapers/tjing.js with AbortController timeout (8s), error-object returns, and verbatim GraphQL queries - Add getOrCreateLayout() to src/models/course.js - New /api/tjing/search and /api/tjing/import/:tjingId routes; course-table route now includes layoutCount/activeLayoutCount via LEFT JOIN aggregation - Rewrite courses.ejs: action-card with Find/Import tabs, results bar, HTMX course-table-region with body:refresh trigger - Rewrite course-table.ejs: CSS-grid div structure replacing , lazy-load expanded layouts via JS htmx.ajax - Rewrite course-layouts.ejs: layout-card chips with rating tier colouring, collapsible inactive layouts section - Rewrite courses.js: tab switching, live client-side filter, count display, Tjing search/import using DOM API (no innerHTML with untrusted data) - Rewrite courses.css: full new design system using project tokens --- public/css/courses.css | 512 ++++++++++++++++++++++++------ public/js/courses.js | 317 +++++++++++++++--- src/models/course.js | 23 ++ src/routes/courses.js | 81 ++++- src/scrapers/tjing.js | 111 +++++++ views/pages/courses.ejs | 64 ++-- views/partials/course-layouts.ejs | 128 ++++---- views/partials/course-table.ejs | 82 +++-- 8 files changed, 1007 insertions(+), 311 deletions(-) create mode 100644 src/scrapers/tjing.js diff --git a/public/css/courses.css b/public/css/courses.css index ffc47b6..63c4dc2 100644 --- a/public/css/courses.css +++ b/public/css/courses.css @@ -2,133 +2,447 @@ Courses Page ═══════════════════════════════════════════════════ */ -/* ── Controls ─────────────────────────────────── */ +/* ── Action Card (tabs + inputs) ─────────────────── */ -.controls { +.action-card { + background: var(--paper); + border: 1px solid var(--line-2); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + margin-bottom: 16px; +} + +.action-card-tabs { display: flex; - justify-content: flex-end; - margin-bottom: 20px; + border-bottom: 1px solid var(--line-2); } -/* ── Search ───────────────────────────────────── */ - -.search-results-info { - text-align: center; - margin: 10px 0; - color: var(--text-muted); - font-size: 13px; +.action-tab { + padding: 12px 18px; + background: transparent; + border: 0; + color: var(--ink-2); + font: 600 14px/1.2 var(--font-sans); + cursor: pointer; + transition: color 120ms; } -/* ── Layouts ──────────────────────────────────── */ - -.layouts-container { - padding: 16px; +.action-tab:hover { + color: var(--ink); } -.layouts-container h4 { - margin: 0 0 12px 0; - font-size: 13px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-secondary); +.action-tab.is-active { + color: var(--ink); + box-shadow: inset 0 -2px 0 var(--accent); } -.layout-item { - padding: 12px 14px; - margin: 4px 0; - background: var(--surface-1); +.action-card-body { + padding: 16px 20px; +} + +.action-pane[hidden] { + display: none; +} + +.action-card-body input[type=text] { + width: 100%; + height: 40px; + padding: 0 14px; + border: 1px solid var(--line-2); border-radius: var(--radius-sm); - border: 1px solid var(--border); + background: var(--paper-2); + font: 14px/1.2 var(--font-sans); +} + +.action-card-body input[type=text]:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px color-mix(in oklch, var(--accent) 14%, transparent); +} + +.action-hint { + margin: 8px 0 0; + font-size: 11.5px; + color: var(--ink-3); +} + +.tjing-search-row { display: flex; - justify-content: space-between; + gap: 10px; align-items: center; - transition: border-color var(--transition), box-shadow var(--transition); } -.layout-item:hover { - border-color: var(--accent-border); - box-shadow: var(--shadow-sm); +.tjing-search-row input[type=text] { + flex: 1; } -.layout-name { - font-weight: 600; - color: var(--text-primary); - font-size: 14px; -} +/* ── Buttons ──────────────────────────────────────── */ -.layout-par { - color: var(--accent); - font-weight: 700; - font-size: 14px; - font-variant-numeric: tabular-nums; +.btn-primary { + background: var(--accent); + color: #fff; + border: 0; + height: 40px; + padding: 0 16px; + border-radius: var(--radius-sm); + font: 600 14px/1 var(--font-sans); + cursor: pointer; white-space: nowrap; } +.btn-primary:hover { + filter: brightness(1.05); +} + +.btn-primary:disabled { + opacity: .6; + cursor: not-allowed; +} + +.btn-pill { + padding: 6px 12px; + background: var(--accent); + color: #fff; + border: 0; + border-radius: 999px; + font: 600 12.5px/1 var(--font-sans); + cursor: pointer; + height: 28px; + white-space: nowrap; +} + +.btn-pill:disabled { + opacity: .6; + cursor: default; +} + +/* ── Results bar ─────────────────────────────────── */ + +.results-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 4px; + font-variant-numeric: tabular-nums; +} + +.results-count { + color: var(--ink-2); + font-size: 13px; +} + +.results-count strong { + color: var(--ink); + font-weight: 600; +} + +.results-link { + color: var(--accent); + text-decoration: none; + font: 500 13.5px/1.2 var(--font-sans); +} + +/* ── Course grid ─────────────────────────────────── */ + +.course-grid { + background: var(--paper); + border: 1px solid var(--line-2); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.course-row { + display: grid; + grid-template-columns: minmax(280px, 2fr) minmax(140px, 1fr) minmax(140px, 0.9fr) 72px; + align-items: center; + gap: 14px; + padding: 14px 20px; + border-bottom: 1px solid var(--line-2); + cursor: pointer; + transition: background 120ms; +} + +.course-row:hover { + background: var(--paper-2); +} + +.course-row.row-open { + background: var(--paper-2); + box-shadow: inset 3px 0 0 var(--accent); +} + +.course-row[hidden], +.expanded-content[hidden] { + display: none; +} + +.course-row.row-open .icon-chev i { + transform: rotate(180deg); +} + +.course-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.course-name { + color: var(--accent); + font: 600 14px/1.3 var(--font-sans); +} + +.course-meta { + color: var(--ink-3); + font-size: 12.5px; +} + +.course-city { + color: var(--ink); + font-size: 14px; +} + +.course-updated { + color: var(--ink-3); + font-family: var(--font-mono); + font-size: 12.5px; +} + +.course-actions { + display: flex; + gap: 4px; + justify-content: flex-end; +} + +/* ── Expanded layout panel ───────────────────────── */ + +.expanded-content { + display: none; +} + +.expanded-content.is-open { + display: block; + animation: expandIn .2s ease; +} + +.expanded-cell { + padding: 22px 28px 28px; + background: color-mix(in oklch, var(--accent) 4%, var(--paper-2)); + border-bottom: 1px solid var(--line-2); +} + +/* ── Layout list ─────────────────────────────────── */ + +.layouts-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 12px; +} + +.layouts-kicker { + font: 600 11px/1 var(--font-sans); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-3); +} + +.layouts-count { + font-size: 12.5px; + color: var(--ink-3); +} + +.layout-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.layout-card { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 14px; + padding: 12px 18px; + background: var(--paper); + border: 1px solid var(--line-2); + border-radius: var(--radius-sm); + transition: border-color 120ms, box-shadow 120ms; +} + +.layout-card:hover { + border-color: var(--line); + box-shadow: var(--shadow-card); +} + +.layout-card--inactive { + background: transparent; + border-style: dashed; + opacity: 0.65; +} + +.layout-card--inactive .layout-name { + color: var(--ink-2); + font-weight: 500; +} + +.layout-info { + display: flex; + align-items: baseline; + gap: 14px; + min-width: 0; +} + +.layout-name { + font: 600 13.5px/1.2 var(--font-sans); + color: var(--ink); +} + +.layout-last-played { + font: 12.5px/1 var(--font-mono); + color: var(--ink-3); +} + +.layout-never-played { + font-size: 12.5px; + color: var(--down); +} + +.layout-chips { + display: flex; + gap: 14px; + align-items: center; +} + +.chip { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 8px; + font: 600 12.5px/1 var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.chip-par { + color: var(--accent); + background: color-mix(in oklch, var(--accent) 8%, transparent); +} + +.chip-rating--green { + color: oklch(0.55 0.15 150); + background: color-mix(in oklch, oklch(0.55 0.15 150) 10%, transparent); +} + +.chip-rating--amber { + color: oklch(0.55 0.12 100); + background: color-mix(in oklch, oklch(0.55 0.12 100) 10%, transparent); +} + +.chip-rating--orange { + color: oklch(0.55 0.10 50); + background: color-mix(in oklch, oklch(0.55 0.10 50) 10%, transparent); +} + +/* ── Inactive layouts collapsible ────────────────── */ + +.inactive-layouts { + margin-top: 14px; + background: var(--paper-2); + border: 1px solid var(--line-2); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.inactive-toggle { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px 16px; + background: transparent; + border: 0; + font: 500 13.5px/1.2 var(--font-sans); + color: var(--ink-2); + cursor: pointer; +} + +.inactive-toggle .icon-chev { + transition: transform 180ms; + display: inline-block; +} + +.inactive-toggle.is-open .icon-chev { + transform: rotate(180deg); +} + +.inactive-layouts-body { + padding: 0 12px 12px; +} + +.inactive-layouts-body[hidden] { + display: none; +} + +/* ── Tjing results ───────────────────────────────── */ + +#tjing-results { + margin-top: 12px; +} + +.tjing-result { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid var(--line-2); +} + +.tjing-result:last-child { + border-bottom: 0; +} + +.tjing-result-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.tjing-result-name { + font: 600 14px/1.3 var(--font-sans); + color: var(--ink); +} + +.tjing-result-address { + font-size: 12.5px; + color: var(--ink-3); +} + +.tjing-error { + color: var(--down); + font-size: 13px; + padding: 12px 0; +} + +/* ── No-layouts message ──────────────────────────── */ + .no-layouts { text-align: center; - color: var(--text-muted); + color: var(--ink-3); font-style: italic; padding: 24px; font-size: 13px; } -/* ── Inactive Layouts Accordion ───────────────── */ +/* ── Loading placeholder ─────────────────────────── */ -.inactive-layouts-accordion { - margin-top: 16px; - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface-2); - overflow: hidden; -} - -.accordion-header { - padding: 12px 16px; - cursor: pointer; - user-select: none; - display: flex; - justify-content: space-between; - align-items: center; - background: var(--surface-3); - transition: background var(--transition); -} - -.accordion-header:hover { - background: var(--border); -} - -.accordion-header-text { - font-weight: 600; - color: var(--text-secondary); +.loading { + text-align: center; + color: var(--ink-3); font-size: 13px; + padding: 24px; } - -.accordion-icon { - transition: transform 0.3s ease; - color: var(--text-muted); - font-size: 12px; -} - -.accordion-icon.expanded { - transform: rotate(180deg); -} - -.accordion-content { - max-height: 0; - overflow: hidden; - transition: max-height 0.3s ease-out; - padding: 0 12px; -} - -.accordion-content.expanded { - max-height: 2000px; - padding: 12px; - transition: max-height 0.5s ease-in; -} - -.layout-item.inactive { - opacity: 0.6; - border-style: dashed; -} - diff --git a/public/js/courses.js b/public/js/courses.js index 2f692c9..00a21e3 100644 --- a/public/js/courses.js +++ b/public/js/courses.js @@ -1,47 +1,111 @@ -function toggleAccordion(accordionId) { - const content = document.getElementById(accordionId); - const icon = document.getElementById(`${accordionId}-icon`); +// ── Tab switching ────────────────────────────────── +function initCourseTabs() { + var tabs = document.querySelectorAll('.action-tab'); + tabs.forEach(function(tab) { + tab.addEventListener('click', function() { + tabs.forEach(function(t) { + t.classList.remove('is-active'); + t.setAttribute('aria-selected', 'false'); + }); + tab.classList.add('is-active'); + tab.setAttribute('aria-selected', 'true'); - if (content.classList.contains('expanded')) { - content.classList.remove('expanded'); - icon.classList.remove('expanded'); - } else { - content.classList.add('expanded'); - icon.classList.add('expanded'); - } + document.querySelectorAll('.action-pane').forEach(function(pane) { + pane.hidden = true; + pane.classList.remove('is-active'); + }); + + var targetId = 'tab-pane-' + tab.dataset.tab; + var pane = document.getElementById(targetId); + if (pane) { + pane.hidden = false; + pane.classList.add('is-active'); + } + }); + }); } +// ── Live filter ──────────────────────────────────── +function initCourseLiveFilter() { + var input = document.getElementById('course-filter-input'); + if (!input) return; + + input.addEventListener('input', function() { + var q = input.value.toLowerCase().trim(); + var rows = document.querySelectorAll('.course-row'); + var visible = 0; + + rows.forEach(function(row) { + var name = row.dataset.courseName || ''; + var city = row.dataset.courseCity || ''; + var match = !q || name.includes(q) || city.includes(q); + + row.hidden = !match; + + // Keep the expanded content sibling in sync + var next = row.nextElementSibling; + if (next && next.classList.contains('expanded-content')) { + next.hidden = !match; + } + + if (match) visible++; + }); + + var visibleEl = document.getElementById('visible-count'); + if (visibleEl) visibleEl.textContent = visible; + }); +} + +// ── Count display ────────────────────────────────── +function initCourseCounts() { + var grid = document.querySelector('.course-grid'); + var total = grid ? parseInt(grid.dataset.totalCount || '0', 10) : 0; + var rows = document.querySelectorAll('.course-row'); + var visible = 0; + rows.forEach(function(r) { if (!r.hidden) visible++; }); + + var totalEl = document.getElementById('total-count'); + var visibleEl = document.getElementById('visible-count'); + if (totalEl) totalEl.textContent = total; + if (visibleEl) visibleEl.textContent = visible || total; +} + +// ── Course row expand/collapse ───────────────────── function toggleCourseLayouts(courseId) { - const layoutsRow = document.getElementById(`layouts-${courseId}`); - const layoutsContainer = document.getElementById(`layouts-container-${courseId}`); + var row = document.querySelector('.course-row[data-course-id="' + courseId + '"]'); + var content = document.getElementById('course-layouts-' + courseId); + if (!row || !content) return; - if (layoutsRow.style.display === 'table-row') { - layoutsRow.style.display = 'none'; - return; + var isOpen = content.classList.contains('is-open'); + + if (isOpen) { + content.classList.remove('is-open'); + row.classList.remove('row-open'); + } else { + content.classList.add('is-open'); + row.classList.add('row-open'); + + // Lazy-load layouts on first expand + var cell = content.querySelector('.expanded-cell'); + if (cell && cell.dataset.loaded !== 'true') { + cell.dataset.loaded = 'true'; + htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: cell, swap: 'innerHTML' }); + } } - - layoutsRow.style.display = 'table-row'; - - if (layoutsContainer.dataset.loaded === 'true') { - return; - } - - htmx.ajax('GET', `/partials/course-layouts/${courseId}`, {target: `#layouts-container-${courseId}`, swap: 'innerHTML'}); - layoutsContainer.dataset.loaded = 'true'; } // ── Mobile course card toggle ────────────────────── -let openMobileCourseId = null; +var openMobileCourseId = null; function toggleMobileCourseLayouts(courseId) { - const card = document.getElementById('m-course-' + courseId); + var card = document.getElementById('m-course-' + courseId); if (!card) return; - const isOpen = card.classList.contains('is-open'); + var isOpen = card.classList.contains('is-open'); // Close previously open card if (openMobileCourseId !== null && openMobileCourseId !== courseId) { - const prevCard = document.getElementById('m-course-' + openMobileCourseId); + var prevCard = document.getElementById('m-course-' + openMobileCourseId); if (prevCard) { prevCard.classList.remove('is-open'); prevCard.setAttribute('aria-expanded', 'false'); @@ -61,25 +125,39 @@ function toggleMobileCourseLayouts(courseId) { openMobileCourseId = courseId; // Lazy-load layouts on first expand - const container = document.getElementById('m-layouts-container-' + courseId); + 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'; } } +// ── Inactive layouts toggle ──────────────────────── +function toggleInactiveLayouts(btn) { + var body = btn.nextElementSibling; + if (!body) return; + + var isOpen = btn.classList.contains('is-open'); + btn.classList.toggle('is-open', !isOpen); + btn.setAttribute('aria-expanded', String(!isOpen)); + body.hidden = isOpen; +} + +// ── Scrape courses ───────────────────────────────── async function scrapeCourses() { - const btn = document.getElementById('scrape-courses-btn'); - btn.disabled = true; - btn.textContent = 'Scraping...'; + var btn = document.getElementById('scrape-courses-btn'); + if (btn) { + btn.disabled = true; + btn.textContent = 'Scraping...'; + } try { - const response = await fetch('/api/scrape-courses', { method: 'POST' }); - const data = await response.json(); + var response = await fetch('/api/scrape-courses', { method: 'POST' }); + var data = await response.json(); if (data.success) { alert(data.message); - htmx.ajax('GET', '/partials/course-table', '#courses-table'); + htmx.trigger(document.body, 'refresh'); } else { alert('Failed to scrape courses'); } @@ -87,31 +165,33 @@ async function scrapeCourses() { console.error('Error scraping courses:', error); alert('Error scraping courses'); } finally { - btn.disabled = false; - btn.textContent = 'Scrape Courses'; + if (btn) { + btn.disabled = false; + btn.textContent = 'Scrape Courses'; + } } } -async function scrapeLayouts(courseId, courseName) { - const icon = document.querySelector(`#row-${courseId} .refresh-icon`); - icon.classList.add('spinning'); +// ── Scrape layouts for a course ──────────────────── +async function scrapeLayouts(courseId, btn) { + if (btn) btn.classList.add('spinning'); try { - const response = await fetch(`/api/scrape-layouts/${courseId}`, { method: 'POST' }); - const data = await response.json(); + var response = await fetch('/api/scrape-layouts/' + courseId, { method: 'POST' }); + var data = await response.json(); if (response.status === 409) { alert(data.message || 'Scrape already in progress for this course. Please wait.'); } else if (data.success) { - const layoutsContainer = document.getElementById(`layouts-container-${courseId}`); - layoutsContainer.dataset.loaded = 'false'; - - const layoutsRow = document.getElementById(`layouts-${courseId}`); - if (layoutsRow.style.display === 'table-row') { - htmx.ajax('GET', `/partials/course-layouts/${courseId}`, {target: `#layouts-container-${courseId}`, swap: 'innerHTML'}); - layoutsContainer.dataset.loaded = 'true'; + // Reload expanded layout content if currently open + var content = document.getElementById('course-layouts-' + courseId); + if (content && content.classList.contains('is-open')) { + var cell = content.querySelector('.expanded-cell'); + if (cell) { + cell.dataset.loaded = 'true'; + htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: cell, swap: 'innerHTML' }); + } } - alert(data.message); } else { alert('Failed to scrape layouts'); @@ -120,6 +200,141 @@ async function scrapeLayouts(courseId, courseName) { console.error('Error scraping layouts:', error); alert('Error scraping layouts'); } finally { - icon.classList.remove('spinning'); + if (btn) btn.classList.remove('spinning'); } } + +// ── Tjing search ─────────────────────────────────── +async function searchTjing() { + var input = document.getElementById('tjing-search-input'); + var btn = document.getElementById('tjing-search-btn'); + var container = document.getElementById('tjing-results'); + if (!input || !container) return; + + var q = input.value.trim(); + if (!q) return; + + btn.disabled = true; + + // Clear previous results safely + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + try { + var response = await fetch('/api/tjing/search?q=' + encodeURIComponent(q)); + var data; + try { + data = await response.json(); + } catch (e) { + var errP = document.createElement('p'); + errP.className = 'tjing-error'; + errP.textContent = 'Invalid response from server.'; + container.appendChild(errP); + return; + } + + if (!response.ok || data.error) { + var errP2 = document.createElement('p'); + errP2.className = 'tjing-error'; + errP2.textContent = 'Error: ' + (data.error || 'Search failed'); + container.appendChild(errP2); + return; + } + + var results = data.results || []; + if (results.length === 0) { + var noResults = document.createElement('p'); + noResults.className = 'tjing-error'; + noResults.textContent = 'No courses found on Tjing.'; + container.appendChild(noResults); + return; + } + + results.forEach(function(course) { + var item = document.createElement('div'); + item.className = 'tjing-result'; + + var info = document.createElement('div'); + info.className = 'tjing-result-info'; + + var nameSpan = document.createElement('span'); + nameSpan.className = 'tjing-result-name'; + nameSpan.textContent = course.name || ''; + + var addrSpan = document.createElement('span'); + addrSpan.className = 'tjing-result-address'; + addrSpan.textContent = course.address || ''; + + info.appendChild(nameSpan); + info.appendChild(addrSpan); + + var importBtn = document.createElement('button'); + importBtn.className = 'btn-pill'; + importBtn.textContent = 'Import'; + (function(id, b) { + b.addEventListener('click', function() { importFromTjing(id, b); }); + })(course.id, importBtn); + + item.appendChild(info); + item.appendChild(importBtn); + container.appendChild(item); + }); + } catch (error) { + console.error('Error searching Tjing:', error); + var errFallback = document.createElement('p'); + errFallback.className = 'tjing-error'; + errFallback.textContent = 'Failed to search Tjing.'; + container.appendChild(errFallback); + } finally { + btn.disabled = false; + } +} + +// ── Tjing import ─────────────────────────────────── +async function importFromTjing(tjingId, btn) { + btn.disabled = true; + btn.textContent = 'Importing…'; + + try { + var response = await fetch('/api/tjing/import/' + encodeURIComponent(tjingId), { method: 'POST' }); + var data; + try { + data = await response.json(); + } catch (e) { + btn.textContent = 'Error'; + btn.disabled = false; + return; + } + + if (!response.ok || data.error) { + btn.textContent = 'Error: ' + (data.error || 'Import failed'); + btn.disabled = false; + return; + } + + btn.textContent = 'Imported ✓'; + // Trigger table reload + htmx.trigger(document.body, 'refresh'); + } catch (error) { + console.error('Error importing from Tjing:', error); + btn.textContent = 'Failed'; + btn.disabled = false; + } +} + +// ── Init ─────────────────────────────────────────── +function initAll() { + initCourseTabs(); + initCourseLiveFilter(); + initCourseCounts(); +} + +document.addEventListener('DOMContentLoaded', initAll); + +document.addEventListener('htmx:afterSwap', function(evt) { + if (evt.detail && evt.detail.target && evt.detail.target.id === 'course-table-region') { + initCourseLiveFilter(); + initCourseCounts(); + } +}); diff --git a/src/models/course.js b/src/models/course.js index 48c73a0..748c8a8 100644 --- a/src/models/course.js +++ b/src/models/course.js @@ -70,10 +70,33 @@ function updateLayoutRating(courseId, layoutName, par, meanRating, ratingCount, }); } +function getOrCreateLayout(courseId, name, par) { + return new Promise((resolve, reject) => { + db.get( + 'SELECT id FROM layouts WHERE course_id = ? AND name = ? AND par = ?', + [courseId, name, par], + (err, row) => { + if (err) return reject(err); + if (row) return resolve(row.id); + + db.run( + 'INSERT INTO layouts (course_id, name, par) VALUES (?, ?, ?)', + [courseId, name, par], + function(err) { + if (err) reject(err); + else resolve(this.lastID); + } + ); + } + ); + }); +} + module.exports = { saveCourseToDB, getAllCoursesFromDB, saveLayoutToDB, getLayoutsForCourse, + getOrCreateLayout, updateLayoutRating }; diff --git a/src/routes/courses.js b/src/routes/courses.js index fa0db78..7d5c70e 100644 --- a/src/routes/courses.js +++ b/src/routes/courses.js @@ -1,7 +1,8 @@ const express = require('express'); const router = express.Router(); const { db } = require('../db'); -const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating } = require('../models/course'); +const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating, saveCourseToDB, getOrCreateLayout } = require('../models/course'); +const { searchTjingCourses, getTjingCourse } = require('../scrapers/tjing'); const { launchBrowser } = require('../scrapers/browser'); const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer'); const logger = require('../logger'); @@ -11,19 +12,30 @@ const activeScrapes = new Map(); router.get('/partials/course-table', async (req, res) => { try { - const allCourses = await getAllCoursesFromDB(); - const query = req.query.q || ''; - let courses = allCourses; + const oneYearAgo = new Date(); + oneYearAgo.setDate(oneYearAgo.getDate() - 365); + const oneYearAgoStr = oneYearAgo.toISOString().slice(0, 10); - if (query) { - const q = query.toLowerCase(); - courses = allCourses.filter(c => - c.name.toLowerCase().includes(q) || c.city.toLowerCase().includes(q) + const allCourses = await new Promise((resolve, reject) => { + db.all( + `SELECT c.*, + COUNT(l.id) AS layoutCount, + SUM(CASE WHEN l.last_played >= ? THEN 1 ELSE 0 END) AS activeLayoutCount + FROM courses c + LEFT JOIN layouts l ON l.course_id = c.id + GROUP BY c.id + ORDER BY c.name ASC`, + [oneYearAgoStr], + (err, rows) => { + if (err) reject(err); + else resolve(rows); + } ); - } + }); - res.render('../partials/course-table', { courses, query, total: allCourses.length }); + res.render('../partials/course-table', { courses: allCourses }); } catch (error) { + logger.error({ err: error }, 'Error loading course table'); res.status(500).send('

Error loading courses. Please try again.

'); } }); @@ -369,4 +381,53 @@ router.post('/api/scrape-event-results/:courseId', async (req, res) => { } }); +// Search Tjing for courses +router.get('/api/tjing/search', async (req, res) => { + const { q } = req.query; + if (!q || q.trim().length === 0) { + return res.json({ results: [] }); + } + + const result = await searchTjingCourses(q.trim()); + if (result.error) { + logger.warn({ q, err: result.error }, 'Tjing search error'); + return res.status(502).json({ error: result.error }); + } + + res.json({ results: result.data }); +}); + +// Import a course from Tjing +router.post('/api/tjing/import/:tjingId', async (req, res) => { + const { tjingId } = req.params; + + const result = await getTjingCourse(tjingId); + if (result.error) { + logger.warn({ tjingId, err: result.error }, 'Tjing import error'); + return res.status(502).json({ error: result.error }); + } + + const courseData = result.data; + + try { + const courseId = await saveCourseToDB({ + name: courseData.name, + link: `https://tjing.se/courses/${tjingId}`, + city: courseData.address || '' + }); + + let layoutsImported = 0; + for (const layout of courseData.layouts) { + await getOrCreateLayout(courseId, layout.name, layout.par); + layoutsImported++; + } + + logger.info({ courseId, name: courseData.name, layoutsImported }, 'Imported course from Tjing'); + res.json({ courseId, layoutsImported }); + } catch (err) { + logger.error({ err, tjingId }, 'Failed to save Tjing course to DB'); + res.status(500).json({ error: 'Failed to save course to database' }); + } +}); + module.exports = router; diff --git a/src/scrapers/tjing.js b/src/scrapers/tjing.js new file mode 100644 index 0000000..157a9b9 --- /dev/null +++ b/src/scrapers/tjing.js @@ -0,0 +1,111 @@ +const logger = require('../logger'); + +const TJING_API = 'https://api.tjing.se/graphql'; +const FETCH_TIMEOUT_MS = 8000; + +async function tjingFetch(query) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + let response; + try { + response = await fetch(TJING_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + signal: controller.signal + }); + } catch (err) { + if (err.name === 'AbortError') { + return { error: 'Tjing API request timed out' }; + } + return { error: `Network error: ${err.message}` }; + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + return { error: `Tjing API returned ${response.status}` }; + } + + let data; + try { + data = await response.json(); + } catch (err) { + return { error: 'Invalid JSON from Tjing API' }; + } + + if (data.errors && data.errors.length > 0) { + return { error: data.errors[0].message }; + } + + return { data: data.data }; +} + +async function searchTjingCourses(searchTerm) { + const query = `{ + courses(first: 10, filter: { search: "${searchTerm.replace(/"/g, '\\"')}" }) { + id + name + address + type + } + }`; + + const result = await tjingFetch(query); + if (result.error) return result; + + return { data: result.data.courses || [] }; +} + +async function getTjingCourse(courseId) { + const query = `{ + course(courseId: "${courseId.replace(/"/g, '\\"')}") { + id + name + address + layouts { + id + name + published + latestVersion { + holes { + number + par + } + } + } + } + }`; + + const result = await tjingFetch(query); + if (result.error) return result; + + const course = result.data.course; + if (!course) { + return { error: 'Course not found' }; + } + + // Calculate total par per layout from holes + const layouts = (course.layouts || []) + .filter(l => l.published && l.latestVersion && l.latestVersion.holes.length > 0) + .map(l => ({ + name: l.name, + par: l.latestVersion.holes.reduce((sum, h) => sum + h.par, 0), + holes: l.latestVersion.holes.length + })); + + return { + data: { + name: course.name, + address: course.address, + tjingId: course.id, + layouts + } + }; +} + +module.exports = { + searchTjingCourses, + getTjingCourse +}; diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index 8b94723..d13646e 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -1,43 +1,33 @@ <% var body = ` -
-

Find Courses

-
- - -
+
+
+
+ + +
+
+
+ +

Filters the list below as you type.

+
+ +
+
- -
- - -
+
+ Showing 0 of 0 courses + View all → +
- - - -
+
+
`; %> <%- include('../partials/layout', { @@ -46,4 +36,4 @@ cssFiles: ['courses.css'], jsFiles: ['courses.js'], body: body -}) %> \ No newline at end of file +}) %> diff --git a/views/partials/course-layouts.ejs b/views/partials/course-layouts.ejs index 430427d..76a866b 100644 --- a/views/partials/course-layouts.ejs +++ b/views/partials/course-layouts.ejs @@ -1,71 +1,61 @@ -<% if (layouts.length === 0) { %> -
No layouts found. Click the refresh icon to scrape layouts.
+<% if (!layouts || layouts.length === 0) { %> +
No layouts found. Click the refresh button to scrape layouts.
<% } else { - var oneYearAgo = new Date(); - oneYearAgo.setDate(oneYearAgo.getDate() - 365); - - var activeLayouts = []; - var inactiveLayouts = []; - - layouts.forEach(function(layout) { - if (layout.last_played) { - var lastPlayedDate = new Date(layout.last_played); - if (lastPlayedDate >= oneYearAgo) { - activeLayouts.push(layout); - } else { - inactiveLayouts.push(layout); - } - } else { - inactiveLayouts.push(layout); - } - }); + var oneYearAgo = new Date(); + oneYearAgo.setDate(oneYearAgo.getDate() - 365); + var activeLayouts = []; + var inactiveLayouts = []; + layouts.forEach(function(l) { + if (l.last_played && new Date(l.last_played) >= oneYearAgo) { + activeLayouts.push(l); + } else { + inactiveLayouts.push(l); + } + }); + function ratingTier(r) { + if (r == null) return null; + if (r >= 970) return 'green'; + if (r >= 940) return 'amber'; + return 'orange'; + } %> -

Layouts:

- - <% if (activeLayouts.length > 0) { %> - <% activeLayouts.forEach(function(layout) { - var ratingDisplay = layout.mean_rating ? - 'Rating: ' + layout.mean_rating + '' : ''; - var dateDisplay = layout.last_played ? - 'Last played: ' + new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + '' : ''; - %> -
-
- <%= layout.name %> - <%- dateDisplay %> -
- Par <%= layout.par %><%- ratingDisplay %> -
- <% }); %> - <% } %> - - <% if (inactiveLayouts.length > 0) { %> -
-
- Inactive Layouts (<%= inactiveLayouts.length %>) - Not played in last year - -
-
- <% inactiveLayouts.forEach(function(layout) { - var ratingDisplay = layout.mean_rating ? - 'Rating: ' + layout.mean_rating + '' : ''; - var dateDisplay = layout.last_played ? - 'Last played: ' + new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + '' : - 'Never played'; - %> -
-
- <%= layout.name %> - <%- dateDisplay %> -
- Par <%= layout.par %><%- ratingDisplay %> -
- <% }); %> -
-
- <% } %> - - <% if (activeLayouts.length === 0 && inactiveLayouts.length === 0) { %> -
No layouts found. Click the refresh icon to scrape layouts.
- <% } %> -<% } %> \ No newline at end of file +
+ LAYOUTS + <%= activeLayouts.length %> active · <%= inactiveLayouts.length %> inactive +
+ +<% if (inactiveLayouts.length > 0) { %> +
+ + +
+<% } %> +<% } %> diff --git a/views/partials/course-table.ejs b/views/partials/course-table.ejs index a6e9899..3260237 100644 --- a/views/partials/course-table.ejs +++ b/views/partials/course-table.ejs @@ -1,47 +1,39 @@ -
- <% if (typeof query !== 'undefined' && query) { %> - Showing <%= courses.length %> of <%= total %> courses - <% } else { %> - Showing all <%= courses.length %> courses - <% } %> -
- -<% if (courses.length === 0) { %> -

No courses found. Click "Scrape Courses" to load Swedish courses from PDGA.

+<% if (!courses || courses.length === 0) { %> +

No courses found. Use "Import from Tjing" or scrape courses from PDGA.

<% } else { %> -
- - - - - - - - - - <% courses.forEach(function(course) { - var lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - %> - - - - - - - - - - <% }); %> - -
Course NameCityLast UpdatedActions
- <%= course.name %> -
<%= course.city %>
-
<%= course.city %><%= lastUpdated %> - '); event.stopPropagation();" title="Scrape layouts for this course"> -
-
-
Click to load layouts...
-
-
- <%- include('course-cards', { courses: courses, query: locals.query, total: locals.total }) %> +
+ <% courses.forEach(function(course) { + var layoutCount = course.layoutCount || 0; + var activeLayoutCount = course.activeLayoutCount || 0; + %> +
" data-course-city="<%= (course.city || '').toLowerCase().replace(/"/g, '"') %>" onclick="toggleCourseLayouts(<%= course.id %>)"> +
+ <%= course.name %> + + <% if (layoutCount > 0) { %> + <% if (activeLayoutCount !== layoutCount) { %> + <%= layoutCount %> layouts · <%= activeLayoutCount %> active + <% } else { %> + <%= layoutCount %> layouts + <% } %> + <% } else { %> + No layouts + <% } %> + +
+
<%= course.city || '—' %>
+
<%= course.last_updated ? new Date(course.last_updated).toISOString().slice(0,10) : '—' %>
+
+ + +
+
+
+
+
Loading layouts…
+
+
+ <% }); %> +
+<%- include('course-cards', { courses: courses }) %> <% } %> From 9cb78c9c98777e40ac1bdc2b8279b717be38446b Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Mon, 25 May 2026 09:54:15 +0200 Subject: [PATCH 2/6] fix: address code-review findings from pass 1 + 2 (#8) - Fix saveCourseToDB returning 0 on conflict by falling back to SELECT - Fix inactive layouts showing 'Never played' when last_played exists - Add .icon-btn.spinning to courses.css for refresh button feedback - Remove duplicate .btn-primary from courses.css (use shared.css version) - Tokenize rating tier colors into --rating-tier-{high,mid,low} CSS vars - Convert var to const/let throughout courses.js - Fix logger.error calls to use {err} object form (pino convention) - Extract RATING_TIER_HIGH/MID constants in course-layouts.ejs scriptlet - Remove dead href='#' View all link from courses.ejs (deferred) - Pass total prop explicitly from course-table.ejs to course-cards.ejs - Remove dead #search-results-info selector from mobile.css - Remove redundant .replace(/"/g, '"') from data attributes in course-table.ejs --- public/css/courses.css | 39 ++++------- public/css/mobile.css | 3 - public/css/shared.css | 5 ++ public/js/courses.js | 108 +++++++++++++++--------------- src/models/course.js | 10 ++- src/routes/courses.js | 10 +-- views/pages/courses.ejs | 2 +- views/partials/course-layouts.ejs | 12 +++- views/partials/course-table.ejs | 4 +- 9 files changed, 97 insertions(+), 96 deletions(-) diff --git a/public/css/courses.css b/public/css/courses.css index 63c4dc2..0cc0843 100644 --- a/public/css/courses.css +++ b/public/css/courses.css @@ -78,26 +78,7 @@ /* ── Buttons ──────────────────────────────────────── */ -.btn-primary { - background: var(--accent); - color: #fff; - border: 0; - height: 40px; - padding: 0 16px; - border-radius: var(--radius-sm); - font: 600 14px/1 var(--font-sans); - cursor: pointer; - white-space: nowrap; -} - -.btn-primary:hover { - filter: brightness(1.05); -} - -.btn-primary:disabled { - opacity: .6; - cursor: not-allowed; -} +/* .btn-primary is defined in shared.css — no override needed here */ .btn-pill { padding: 6px 12px; @@ -332,18 +313,18 @@ } .chip-rating--green { - color: oklch(0.55 0.15 150); - background: color-mix(in oklch, oklch(0.55 0.15 150) 10%, transparent); + color: var(--rating-tier-high); + background: color-mix(in oklch, var(--rating-tier-high) 10%, transparent); } .chip-rating--amber { - color: oklch(0.55 0.12 100); - background: color-mix(in oklch, oklch(0.55 0.12 100) 10%, transparent); + color: var(--rating-tier-mid); + background: color-mix(in oklch, var(--rating-tier-mid) 10%, transparent); } .chip-rating--orange { - color: oklch(0.55 0.10 50); - background: color-mix(in oklch, oklch(0.55 0.10 50) 10%, transparent); + color: var(--rating-tier-low); + background: color-mix(in oklch, var(--rating-tier-low) 10%, transparent); } /* ── Inactive layouts collapsible ────────────────── */ @@ -386,6 +367,12 @@ display: none; } +/* ── Icon button spin state (keyframes defined in shared.css) ─── */ + +.icon-btn.spinning { + animation: spin 0.8s linear infinite; +} + /* ── Tjing results ───────────────────────────────── */ #tjing-results { diff --git a/public/css/mobile.css b/public/css/mobile.css index f210161..ccc5c87 100644 --- a/public/css/mobile.css +++ b/public/css/mobile.css @@ -64,9 +64,6 @@ display: none; } - /* Hide search result info text on courses (mobile has section-head) */ - #search-results-info { display: none; } - /* ── Container ──────────────────────────────────── */ .container { diff --git a/public/css/shared.css b/public/css/shared.css index 7e43319..e411128 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -34,6 +34,11 @@ --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, monospace; + /* ── Rating tier tokens ───────────────────────── */ + --rating-tier-high: oklch(0.55 0.15 150); + --rating-tier-mid: oklch(0.55 0.12 100); + --rating-tier-low: oklch(0.55 0.10 50); + /* legacy token aliases — remove as components migrate */ --surface-0: var(--bg); --surface-1: var(--paper); diff --git a/public/js/courses.js b/public/js/courses.js index 00a21e3..1d4e09c 100644 --- a/public/js/courses.js +++ b/public/js/courses.js @@ -1,6 +1,6 @@ // ── Tab switching ────────────────────────────────── function initCourseTabs() { - var tabs = document.querySelectorAll('.action-tab'); + const tabs = document.querySelectorAll('.action-tab'); tabs.forEach(function(tab) { tab.addEventListener('click', function() { tabs.forEach(function(t) { @@ -15,8 +15,8 @@ function initCourseTabs() { pane.classList.remove('is-active'); }); - var targetId = 'tab-pane-' + tab.dataset.tab; - var pane = document.getElementById(targetId); + const targetId = 'tab-pane-' + tab.dataset.tab; + const pane = document.getElementById(targetId); if (pane) { pane.hidden = false; pane.classList.add('is-active'); @@ -27,23 +27,23 @@ function initCourseTabs() { // ── Live filter ──────────────────────────────────── function initCourseLiveFilter() { - var input = document.getElementById('course-filter-input'); + const input = document.getElementById('course-filter-input'); if (!input) return; input.addEventListener('input', function() { - var q = input.value.toLowerCase().trim(); - var rows = document.querySelectorAll('.course-row'); - var visible = 0; + const q = input.value.toLowerCase().trim(); + const rows = document.querySelectorAll('.course-row'); + let visible = 0; rows.forEach(function(row) { - var name = row.dataset.courseName || ''; - var city = row.dataset.courseCity || ''; - var match = !q || name.includes(q) || city.includes(q); + const name = row.dataset.courseName || ''; + const city = row.dataset.courseCity || ''; + const match = !q || name.includes(q) || city.includes(q); row.hidden = !match; // Keep the expanded content sibling in sync - var next = row.nextElementSibling; + const next = row.nextElementSibling; if (next && next.classList.contains('expanded-content')) { next.hidden = !match; } @@ -51,32 +51,32 @@ function initCourseLiveFilter() { if (match) visible++; }); - var visibleEl = document.getElementById('visible-count'); + const visibleEl = document.getElementById('visible-count'); if (visibleEl) visibleEl.textContent = visible; }); } // ── Count display ────────────────────────────────── function initCourseCounts() { - var grid = document.querySelector('.course-grid'); - var total = grid ? parseInt(grid.dataset.totalCount || '0', 10) : 0; - var rows = document.querySelectorAll('.course-row'); - var visible = 0; + const grid = document.querySelector('.course-grid'); + const total = grid ? parseInt(grid.dataset.totalCount || '0', 10) : 0; + const rows = document.querySelectorAll('.course-row'); + let visible = 0; rows.forEach(function(r) { if (!r.hidden) visible++; }); - var totalEl = document.getElementById('total-count'); - var visibleEl = document.getElementById('visible-count'); + const totalEl = document.getElementById('total-count'); + const visibleEl = document.getElementById('visible-count'); if (totalEl) totalEl.textContent = total; if (visibleEl) visibleEl.textContent = visible || total; } // ── Course row expand/collapse ───────────────────── function toggleCourseLayouts(courseId) { - var row = document.querySelector('.course-row[data-course-id="' + courseId + '"]'); - var content = document.getElementById('course-layouts-' + courseId); + const row = document.querySelector('.course-row[data-course-id="' + courseId + '"]'); + const content = document.getElementById('course-layouts-' + courseId); if (!row || !content) return; - var isOpen = content.classList.contains('is-open'); + const isOpen = content.classList.contains('is-open'); if (isOpen) { content.classList.remove('is-open'); @@ -86,7 +86,7 @@ function toggleCourseLayouts(courseId) { row.classList.add('row-open'); // Lazy-load layouts on first expand - var cell = content.querySelector('.expanded-cell'); + const cell = content.querySelector('.expanded-cell'); if (cell && cell.dataset.loaded !== 'true') { cell.dataset.loaded = 'true'; htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: cell, swap: 'innerHTML' }); @@ -95,17 +95,17 @@ function toggleCourseLayouts(courseId) { } // ── Mobile course card toggle ────────────────────── -var openMobileCourseId = null; +let openMobileCourseId = null; function toggleMobileCourseLayouts(courseId) { - var card = document.getElementById('m-course-' + courseId); + const card = document.getElementById('m-course-' + courseId); if (!card) return; - var isOpen = card.classList.contains('is-open'); + const isOpen = card.classList.contains('is-open'); // Close previously open card if (openMobileCourseId !== null && openMobileCourseId !== courseId) { - var prevCard = document.getElementById('m-course-' + openMobileCourseId); + const prevCard = document.getElementById('m-course-' + openMobileCourseId); if (prevCard) { prevCard.classList.remove('is-open'); prevCard.setAttribute('aria-expanded', 'false'); @@ -125,7 +125,7 @@ function toggleMobileCourseLayouts(courseId) { openMobileCourseId = courseId; // Lazy-load layouts on first expand - var container = document.getElementById('m-layouts-container-' + courseId); + const 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'; @@ -134,10 +134,10 @@ function toggleMobileCourseLayouts(courseId) { // ── Inactive layouts toggle ──────────────────────── function toggleInactiveLayouts(btn) { - var body = btn.nextElementSibling; + const body = btn.nextElementSibling; if (!body) return; - var isOpen = btn.classList.contains('is-open'); + const isOpen = btn.classList.contains('is-open'); btn.classList.toggle('is-open', !isOpen); btn.setAttribute('aria-expanded', String(!isOpen)); body.hidden = isOpen; @@ -145,15 +145,15 @@ function toggleInactiveLayouts(btn) { // ── Scrape courses ───────────────────────────────── async function scrapeCourses() { - var btn = document.getElementById('scrape-courses-btn'); + const btn = document.getElementById('scrape-courses-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Scraping...'; } try { - var response = await fetch('/api/scrape-courses', { method: 'POST' }); - var data = await response.json(); + const response = await fetch('/api/scrape-courses', { method: 'POST' }); + const data = await response.json(); if (data.success) { alert(data.message); @@ -177,16 +177,16 @@ async function scrapeLayouts(courseId, btn) { if (btn) btn.classList.add('spinning'); try { - var response = await fetch('/api/scrape-layouts/' + courseId, { method: 'POST' }); - var data = await response.json(); + const response = await fetch('/api/scrape-layouts/' + courseId, { method: 'POST' }); + const data = await response.json(); if (response.status === 409) { alert(data.message || 'Scrape already in progress for this course. Please wait.'); } else if (data.success) { // Reload expanded layout content if currently open - var content = document.getElementById('course-layouts-' + courseId); + const content = document.getElementById('course-layouts-' + courseId); if (content && content.classList.contains('is-open')) { - var cell = content.querySelector('.expanded-cell'); + const cell = content.querySelector('.expanded-cell'); if (cell) { cell.dataset.loaded = 'true'; htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: cell, swap: 'innerHTML' }); @@ -206,12 +206,12 @@ async function scrapeLayouts(courseId, btn) { // ── Tjing search ─────────────────────────────────── async function searchTjing() { - var input = document.getElementById('tjing-search-input'); - var btn = document.getElementById('tjing-search-btn'); - var container = document.getElementById('tjing-results'); + const input = document.getElementById('tjing-search-input'); + const btn = document.getElementById('tjing-search-btn'); + const container = document.getElementById('tjing-results'); if (!input || !container) return; - var q = input.value.trim(); + const q = input.value.trim(); if (!q) return; btn.disabled = true; @@ -222,12 +222,12 @@ async function searchTjing() { } try { - var response = await fetch('/api/tjing/search?q=' + encodeURIComponent(q)); - var data; + const response = await fetch('/api/tjing/search?q=' + encodeURIComponent(q)); + let data; try { data = await response.json(); } catch (e) { - var errP = document.createElement('p'); + const errP = document.createElement('p'); errP.className = 'tjing-error'; errP.textContent = 'Invalid response from server.'; container.appendChild(errP); @@ -235,16 +235,16 @@ async function searchTjing() { } if (!response.ok || data.error) { - var errP2 = document.createElement('p'); + const errP2 = document.createElement('p'); errP2.className = 'tjing-error'; errP2.textContent = 'Error: ' + (data.error || 'Search failed'); container.appendChild(errP2); return; } - var results = data.results || []; + const results = data.results || []; if (results.length === 0) { - var noResults = document.createElement('p'); + const noResults = document.createElement('p'); noResults.className = 'tjing-error'; noResults.textContent = 'No courses found on Tjing.'; container.appendChild(noResults); @@ -252,24 +252,24 @@ async function searchTjing() { } results.forEach(function(course) { - var item = document.createElement('div'); + const item = document.createElement('div'); item.className = 'tjing-result'; - var info = document.createElement('div'); + const info = document.createElement('div'); info.className = 'tjing-result-info'; - var nameSpan = document.createElement('span'); + const nameSpan = document.createElement('span'); nameSpan.className = 'tjing-result-name'; nameSpan.textContent = course.name || ''; - var addrSpan = document.createElement('span'); + const addrSpan = document.createElement('span'); addrSpan.className = 'tjing-result-address'; addrSpan.textContent = course.address || ''; info.appendChild(nameSpan); info.appendChild(addrSpan); - var importBtn = document.createElement('button'); + const importBtn = document.createElement('button'); importBtn.className = 'btn-pill'; importBtn.textContent = 'Import'; (function(id, b) { @@ -282,7 +282,7 @@ async function searchTjing() { }); } catch (error) { console.error('Error searching Tjing:', error); - var errFallback = document.createElement('p'); + const errFallback = document.createElement('p'); errFallback.className = 'tjing-error'; errFallback.textContent = 'Failed to search Tjing.'; container.appendChild(errFallback); @@ -297,8 +297,8 @@ async function importFromTjing(tjingId, btn) { btn.textContent = 'Importing…'; try { - var response = await fetch('/api/tjing/import/' + encodeURIComponent(tjingId), { method: 'POST' }); - var data; + const response = await fetch('/api/tjing/import/' + encodeURIComponent(tjingId), { method: 'POST' }); + let data; try { data = await response.json(); } catch (e) { diff --git a/src/models/course.js b/src/models/course.js index 748c8a8..d2d38f7 100644 --- a/src/models/course.js +++ b/src/models/course.js @@ -8,8 +8,14 @@ function saveCourseToDB(courseData) { ON CONFLICT(link) DO UPDATE SET name = excluded.name, city = excluded.city, last_updated = datetime('now')`, [courseData.name, courseData.link, courseData.city], function(err) { - if (err) reject(err); - else resolve(this.lastID); + if (err) return reject(err); + // node-sqlite3 leaves lastID = 0 when ON CONFLICT triggers an UPDATE. + // Fall back to a SELECT to get the real id in that case. + if (this.lastID !== 0) return resolve(this.lastID); + db.get('SELECT id FROM courses WHERE link = ?', [courseData.link], (err2, row) => { + if (err2) reject(err2); + else resolve(row ? row.id : 0); + }); } ); }); diff --git a/src/routes/courses.js b/src/routes/courses.js index 7d5c70e..8db22b1 100644 --- a/src/routes/courses.js +++ b/src/routes/courses.js @@ -46,7 +46,7 @@ router.get('/partials/course-layouts/:courseId', async (req, res) => { const layouts = await getLayoutsForCourse(courseId); res.render('../partials/course-layouts', { layouts, courseId }); } catch (error) { - logger.error('Error loading course layouts:', error.message); + logger.error({ err: error }, 'Error loading course layouts'); res.status(500).send('
Error loading layouts
'); } }); @@ -56,7 +56,7 @@ router.get('/api/courses', async (req, res) => { const courses = await getAllCoursesFromDB(); res.json(courses); } catch (error) { - logger.error('Error fetching courses:', error.message); + logger.error({ err: error }, 'Error fetching courses'); res.status(500).json({ error: 'Failed to fetch courses' }); } }); @@ -67,7 +67,7 @@ router.get('/api/layouts/:courseId', async (req, res) => { const layouts = await getLayoutsForCourse(courseId); res.json(layouts); } catch (error) { - logger.error('Error fetching layouts:', error.message); + logger.error({ err: error }, 'Error fetching layouts'); res.status(500).json({ error: 'Failed to fetch layouts' }); } }); @@ -226,7 +226,7 @@ router.post('/api/scrape-layouts/:courseId', async (req, res) => { savedCount++; } } catch (err) { - logger.error(` Error updating layout ${layoutDataResult.name}:`, err.message); + logger.error({ err, layoutName: layoutDataResult.name }, 'Error updating layout'); } } } @@ -360,7 +360,7 @@ router.post('/api/scrape-event-results/:courseId', async (req, res) => { savedCount++; } } catch (err) { - logger.error(` Error updating layout ${ld.name}:`, err.message); + logger.error({ err, layoutName: ld.name }, 'Error updating layout'); } } } diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index d13646e..58dc207 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -23,7 +23,7 @@
Showing 0 of 0 courses - View all → + <%# "View all" link deferred — design spec includes it but functionality not yet implemented %>
diff --git a/views/partials/course-layouts.ejs b/views/partials/course-layouts.ejs index 76a866b..389a20b 100644 --- a/views/partials/course-layouts.ejs +++ b/views/partials/course-layouts.ejs @@ -12,10 +12,12 @@ inactiveLayouts.push(l); } }); + var RATING_TIER_HIGH = 970; + var RATING_TIER_MID = 940; function ratingTier(r) { if (r == null) return null; - if (r >= 970) return 'green'; - if (r >= 940) return 'amber'; + if (r >= RATING_TIER_HIGH) return 'green'; + if (r >= RATING_TIER_MID) return 'amber'; return 'orange'; } %> @@ -48,7 +50,11 @@
  • <%= l.name %> - Never played + <% if (l.last_played) { %> + Last played: <%= l.last_played %> + <% } else { %> + Never played + <% } %>
    Par <%= l.par %> diff --git a/views/partials/course-table.ejs b/views/partials/course-table.ejs index 3260237..bbf9b32 100644 --- a/views/partials/course-table.ejs +++ b/views/partials/course-table.ejs @@ -6,7 +6,7 @@ var layoutCount = course.layoutCount || 0; var activeLayoutCount = course.activeLayoutCount || 0; %> -
    " data-course-city="<%= (course.city || '').toLowerCase().replace(/"/g, '"') %>" onclick="toggleCourseLayouts(<%= course.id %>)"> +
    <%= course.name %> @@ -35,5 +35,5 @@
    <% }); %>
    -<%- include('course-cards', { courses: courses }) %> +<%- include('course-cards', { courses: courses, total: courses.length }) %> <% } %> From 88396c9220714dfdfde5c37cff86b31a035a6baa Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Mon, 25 May 2026 10:25:59 +0200 Subject: [PATCH 3/6] fix: remove EJS comment inside template literal causing parse error (#8) --- views/pages/courses.ejs | 1 - 1 file changed, 1 deletion(-) diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index 58dc207..2b6d4b7 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -23,7 +23,6 @@
    Showing 0 of 0 courses - <%# "View all" link deferred — design spec includes it but functionality not yet implemented %>
    From 2035ae0efcb0fb42ffe552f1b6af452cf0dbba70 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Mon, 25 May 2026 10:29:34 +0200 Subject: [PATCH 4/6] fix: use FontAwesome icons matching Players page (#8) --- public/css/courses.css | 5 ----- views/partials/course-layouts.ejs | 2 +- views/partials/course-table.ejs | 8 ++++++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/public/css/courses.css b/public/css/courses.css index 0cc0843..03eeb91 100644 --- a/public/css/courses.css +++ b/public/css/courses.css @@ -367,11 +367,6 @@ display: none; } -/* ── Icon button spin state (keyframes defined in shared.css) ─── */ - -.icon-btn.spinning { - animation: spin 0.8s linear infinite; -} /* ── Tjing results ───────────────────────────────── */ diff --git a/views/partials/course-layouts.ejs b/views/partials/course-layouts.ejs index 389a20b..415e710 100644 --- a/views/partials/course-layouts.ejs +++ b/views/partials/course-layouts.ejs @@ -43,7 +43,7 @@
    From 75b2360e9641033cac97a975a1f7faf18bb601a7 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Mon, 25 May 2026 10:33:47 +0200 Subject: [PATCH 5/6] feat: add table header row to Courses matching Players style (#8) --- public/css/courses.css | 20 ++++++++++++++++++++ views/partials/course-table.ejs | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/public/css/courses.css b/public/css/courses.css index 03eeb91..b5d8cc9 100644 --- a/public/css/courses.css +++ b/public/css/courses.css @@ -162,6 +162,26 @@ transform: rotate(180deg); } +.course-row--header { + height: 48px; + padding: 0 20px; + background: var(--paper-2); + border-bottom: 1px solid var(--line); + cursor: default; +} + +.course-row--header:hover { + background: var(--paper-2); +} + +.course-header-cell { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-3); +} + .course-cell { display: flex; flex-direction: column; diff --git a/views/partials/course-table.ejs b/views/partials/course-table.ejs index 600b2d6..25a010f 100644 --- a/views/partials/course-table.ejs +++ b/views/partials/course-table.ejs @@ -2,6 +2,12 @@

    No courses found. Use "Import from Tjing" or scrape courses from PDGA.

    <% } else { %>
    +
    +
    Course
    +
    City
    +
    Last updated
    +
    +
    <% courses.forEach(function(course) { var layoutCount = course.layoutCount || 0; var activeLayoutCount = course.activeLayoutCount || 0; From cad14def56c86d31a0055b4dd65dac641a7e838d Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Mon, 25 May 2026 10:36:43 +0200 Subject: [PATCH 6/6] style: align course row typography with Players (#8) --- public/css/courses.css | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/public/css/courses.css b/public/css/courses.css index b5d8cc9..fd13e8e 100644 --- a/public/css/courses.css +++ b/public/css/courses.css @@ -189,24 +189,36 @@ } .course-name { + font-weight: 600; + font-size: 14px; + letter-spacing: -0.005em; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +.course-row:hover .course-name, +.course-row.row-open .course-name { color: var(--accent); - font: 600 14px/1.3 var(--font-sans); } .course-meta { + font-size: 11px; color: var(--ink-3); - font-size: 12.5px; } .course-city { - color: var(--ink); font-size: 14px; + color: var(--ink); } .course-updated { - color: var(--ink-3); font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; font-size: 12.5px; + color: var(--ink-3); } .course-actions {