Filters the list below as you type.
+Find and import Swedish courses from tjing.se.
+ +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 = ` -Filters the list below as you type.
+Find and import Swedish courses from tjing.se.
+ +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 { %> -| Course Name | -City | -Last Updated | -Actions | -
|---|---|---|---|
|
- <%= course.name %>
- <%= course.city %>
- |
- <%= course.city %> | -<%= lastUpdated %> | -- '); event.stopPropagation();" title="Scrape layouts for this course"> - | -
|
-
-
- Click to load layouts...
- |
- |||