diff --git a/public/css/courses.css b/public/css/courses.css index ffc47b6..fd13e8e 100644 --- a/public/css/courses.css +++ b/public/css/courses.css @@ -2,133 +2,461 @@ 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 is defined in shared.css — no override needed here */ + +.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-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; + gap: 2px; +} + +.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); +} + +.course-meta { + font-size: 11px; + color: var(--ink-3); +} + +.course-city { + font-size: 14px; + color: var(--ink); +} + +.course-updated { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + font-size: 12.5px; + color: var(--ink-3); +} + +.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: var(--rating-tier-high); + background: color-mix(in oklch, var(--rating-tier-high) 10%, transparent); +} + +.chip-rating--amber { + color: var(--rating-tier-mid); + background: color-mix(in oklch, var(--rating-tier-mid) 10%, transparent); +} + +.chip-rating--orange { + color: var(--rating-tier-low); + background: color-mix(in oklch, var(--rating-tier-low) 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/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 2f692c9..1d4e09c 100644 --- a/public/js/courses.js +++ b/public/js/courses.js @@ -1,33 +1,97 @@ -function toggleAccordion(accordionId) { - const content = document.getElementById(accordionId); - const icon = document.getElementById(`${accordionId}-icon`); +// ── Tab switching ────────────────────────────────── +function initCourseTabs() { + const 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'); + }); + + const targetId = 'tab-pane-' + tab.dataset.tab; + const pane = document.getElementById(targetId); + if (pane) { + pane.hidden = false; + pane.classList.add('is-active'); + } + }); + }); } +// ── Live filter ──────────────────────────────────── +function initCourseLiveFilter() { + const input = document.getElementById('course-filter-input'); + if (!input) return; + + input.addEventListener('input', function() { + const q = input.value.toLowerCase().trim(); + const rows = document.querySelectorAll('.course-row'); + let visible = 0; + + rows.forEach(function(row) { + 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 + const next = row.nextElementSibling; + if (next && next.classList.contains('expanded-content')) { + next.hidden = !match; + } + + if (match) visible++; + }); + + const visibleEl = document.getElementById('visible-count'); + if (visibleEl) visibleEl.textContent = visible; + }); +} + +// ── Count display ────────────────────────────────── +function initCourseCounts() { + 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++; }); + + 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) { - const layoutsRow = document.getElementById(`layouts-${courseId}`); - const layoutsContainer = document.getElementById(`layouts-container-${courseId}`); + const row = document.querySelector('.course-row[data-course-id="' + courseId + '"]'); + const content = document.getElementById('course-layouts-' + courseId); + if (!row || !content) return; - if (layoutsRow.style.display === 'table-row') { - layoutsRow.style.display = 'none'; - return; + const 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 + 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' }); + } } - - 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 ────────────────────── @@ -68,10 +132,24 @@ function toggleMobileCourseLayouts(courseId) { } } +// ── Inactive layouts toggle ──────────────────────── +function toggleInactiveLayouts(btn) { + const body = btn.nextElementSibling; + if (!body) return; + + const 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...'; + if (btn) { + btn.disabled = true; + btn.textContent = 'Scraping...'; + } try { const response = await fetch('/api/scrape-courses', { method: 'POST' }); @@ -79,7 +157,7 @@ async function scrapeCourses() { 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 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) { - 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 + const content = document.getElementById('course-layouts-' + courseId); + if (content && content.classList.contains('is-open')) { + const 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() { + const input = document.getElementById('tjing-search-input'); + const btn = document.getElementById('tjing-search-btn'); + const container = document.getElementById('tjing-results'); + if (!input || !container) return; + + const q = input.value.trim(); + if (!q) return; + + btn.disabled = true; + + // Clear previous results safely + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + try { + const response = await fetch('/api/tjing/search?q=' + encodeURIComponent(q)); + let data; + try { + data = await response.json(); + } catch (e) { + const errP = document.createElement('p'); + errP.className = 'tjing-error'; + errP.textContent = 'Invalid response from server.'; + container.appendChild(errP); + return; + } + + if (!response.ok || data.error) { + const errP2 = document.createElement('p'); + errP2.className = 'tjing-error'; + errP2.textContent = 'Error: ' + (data.error || 'Search failed'); + container.appendChild(errP2); + return; + } + + const results = data.results || []; + if (results.length === 0) { + const noResults = document.createElement('p'); + noResults.className = 'tjing-error'; + noResults.textContent = 'No courses found on Tjing.'; + container.appendChild(noResults); + return; + } + + results.forEach(function(course) { + const item = document.createElement('div'); + item.className = 'tjing-result'; + + const info = document.createElement('div'); + info.className = 'tjing-result-info'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'tjing-result-name'; + nameSpan.textContent = course.name || ''; + + const addrSpan = document.createElement('span'); + addrSpan.className = 'tjing-result-address'; + addrSpan.textContent = course.address || ''; + + info.appendChild(nameSpan); + info.appendChild(addrSpan); + + const 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); + const 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 { + const response = await fetch('/api/tjing/import/' + encodeURIComponent(tjingId), { method: 'POST' }); + let 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..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); + }); } ); }); @@ -70,10 +76,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..8db22b1 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.

'); } }); @@ -34,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
'); } }); @@ -44,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' }); } }); @@ -55,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' }); } }); @@ -214,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'); } } } @@ -348,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'); } } } @@ -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..2b6d4b7 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -1,43 +1,32 @@ <% var body = ` -
-

Find Courses

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

Filters the list below as you type.

+
+ +
+
- -
- - -
+
+ Showing 0 of 0 courses +
- - - -
+
+
`; %> <%- include('../partials/layout', { @@ -46,4 +35,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..415e710 100644 --- a/views/partials/course-layouts.ejs +++ b/views/partials/course-layouts.ejs @@ -1,71 +1,67 @@ -<% 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); + } + }); + var RATING_TIER_HIGH = 970; + var RATING_TIER_MID = 940; + function ratingTier(r) { + if (r == null) return null; + if (r >= RATING_TIER_HIGH) return 'green'; + if (r >= RATING_TIER_MID) 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..25a010f 100644 --- a/views/partials/course-table.ejs +++ b/views/partials/course-table.ejs @@ -1,47 +1,49 @@ -
- <% 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 }) %> +
+
+
Course
+
City
+
Last updated
+
+
+ <% courses.forEach(function(course) { + var layoutCount = course.layoutCount || 0; + var activeLayoutCount = course.activeLayoutCount || 0; + %> +
+
+ <%= 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, total: courses.length }) %> <% } %>