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 <table>, 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
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
+71
-10
@@ -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('<p>Error loading courses. Please try again.</p>');
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user