diff --git a/docs/superpowers/plans/2026-03-19-async-tours.md b/docs/superpowers/plans/2026-03-19-async-tours.md
deleted file mode 100644
index bc1b930..0000000
--- a/docs/superpowers/plans/2026-03-19-async-tours.md
+++ /dev/null
@@ -1,1366 +0,0 @@
-# Async Tours Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add asynchronous tour functionality where players join via tour code, play courses within a date range, report scores, and compete on a live leaderboard.
-
-**Architecture:** Four new DB tables (tours, tour_courses, tour_players, tour_results) following existing SQLite patterns. New model/service/route/view layers matching existing conventions exactly. HTMX partials for live leaderboard updates. Tour code used as URL identifier.
-
-**Tech Stack:** Express, EJS, HTMX, SQLite3, Pino (all existing)
-
----
-
-## File Structure
-
-| File | Responsibility |
-|------|---------------|
-| `src/db.js` | Add 4 new CREATE TABLE statements |
-| `src/models/tour.js` | CRUD operations for all tour tables |
-| `src/services/tour-service.js` | Tour code generation, scoring/leaderboard calculation |
-| `src/routes/tours.js` | API endpoints + partial renders |
-| `views/pages/tours.ejs` | Create tour page |
-| `views/pages/tour.ejs` | Tour view (leaderboard + join + register results) |
-| `views/partials/tour-leaderboard.ejs` | HTMX partial for leaderboard |
-| `views/partials/nav.ejs` | Add Tours nav link |
-| `src/routes/pages.js` | Add /tours page route |
-| `server.js` | Mount tour routes |
-| `public/js/tours.js` | Frontend interaction for tour creation |
-| `public/js/tour.js` | Frontend interaction for tour view |
-| `public/css/tours.css` | Tour-specific styling |
-
----
-
-### Task 1: Database Schema
-
-**Files:**
-- Modify: `src/db.js`
-
-- [ ] **Step 1: Add tour tables to db.js**
-
-Add these four CREATE TABLE statements inside the `db.serialize()` block in `initializeDatabase()`, after the layouts table creation but before the callback that resolves the promise. The last table's callback should chain into the existing `ALTER TABLE layouts` block.
-
-```javascript
-db.run(`
- CREATE TABLE IF NOT EXISTS tours (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- code TEXT NOT NULL UNIQUE,
- start_date DATE NOT NULL,
- end_date DATE NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- )
-`);
-
-db.run(`
- CREATE TABLE IF NOT EXISTS tour_courses (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tour_id INTEGER NOT NULL,
- course_id INTEGER NOT NULL,
- layout_id INTEGER NOT NULL,
- FOREIGN KEY (tour_id) REFERENCES tours(id),
- FOREIGN KEY (course_id) REFERENCES courses(id),
- FOREIGN KEY (layout_id) REFERENCES layouts(id)
- )
-`);
-
-db.run(`
- CREATE TABLE IF NOT EXISTS tour_players (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tour_id INTEGER NOT NULL,
- pdga_number TEXT NOT NULL,
- player_name TEXT NOT NULL,
- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(tour_id, pdga_number),
- FOREIGN KEY (tour_id) REFERENCES tours(id)
- )
-`);
-
-db.run(`
- CREATE TABLE IF NOT EXISTS tour_results (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tour_course_id INTEGER NOT NULL,
- pdga_number TEXT NOT NULL,
- total_strokes INTEGER NOT NULL,
- recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(tour_course_id, pdga_number),
- FOREIGN KEY (tour_course_id) REFERENCES tour_courses(id)
- )
-`);
-```
-
-- [ ] **Step 2: Verify server starts cleanly**
-
-Run: `npm start`
-Expected: "Database initialized successfully" in logs, no errors. Stop server.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/db.js
-git commit -m "feat: add tour database schema (tours, tour_courses, tour_players, tour_results)"
-```
-
----
-
-### Task 2: Tour Model
-
-**Files:**
-- Create: `src/models/tour.js`
-
-- [ ] **Step 1: Create tour model**
-
-Follow the exact pattern from `src/models/course.js` — Promise wrappers around `db.run`/`db.get`/`db.all` with parameterized queries.
-
-```javascript
-const { db } = require('../db');
-
-function createTour(name, code, startDate, endDate) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT INTO tours (name, code, start_date, end_date) VALUES (?, ?, ?, ?)`,
- [name, code, startDate, endDate],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getTourByCode(code) {
- return new Promise((resolve, reject) => {
- db.get(
- 'SELECT * FROM tours WHERE code = ?',
- [code],
- (err, row) => {
- if (err) reject(err);
- else resolve(row);
- }
- );
- });
-}
-
-function addCourseToTour(tourId, courseId, layoutId) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT INTO tour_courses (tour_id, course_id, layout_id) VALUES (?, ?, ?)`,
- [tourId, courseId, layoutId],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getTourCourses(tourId) {
- return new Promise((resolve, reject) => {
- db.all(
- `SELECT tc.id as tour_course_id, tc.course_id, tc.layout_id,
- c.name as course_name, c.city,
- l.name as layout_name, l.par
- FROM tour_courses tc
- JOIN courses c ON tc.course_id = c.id
- JOIN layouts l ON tc.layout_id = l.id
- WHERE tc.tour_id = ?
- ORDER BY c.name ASC`,
- [tourId],
- (err, rows) => {
- if (err) reject(err);
- else resolve(rows);
- }
- );
- });
-}
-
-function joinTour(tourId, pdgaNumber, playerName) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT OR IGNORE INTO tour_players (tour_id, pdga_number, player_name) VALUES (?, ?, ?)`,
- [tourId, pdgaNumber, playerName],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getTourPlayers(tourId) {
- return new Promise((resolve, reject) => {
- db.all(
- `SELECT * FROM tour_players WHERE tour_id = ? ORDER BY joined_at ASC`,
- [tourId],
- (err, rows) => {
- if (err) reject(err);
- else resolve(rows);
- }
- );
- });
-}
-
-function getPlayerInTour(tourId, pdgaNumber) {
- return new Promise((resolve, reject) => {
- db.get(
- `SELECT * FROM tour_players WHERE tour_id = ? AND pdga_number = ?`,
- [tourId, pdgaNumber],
- (err, row) => {
- if (err) reject(err);
- else resolve(row);
- }
- );
- });
-}
-
-function recordResult(tourCourseId, pdgaNumber, totalStrokes) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT OR REPLACE INTO tour_results (tour_course_id, pdga_number, total_strokes) VALUES (?, ?, ?)`,
- [tourCourseId, pdgaNumber, totalStrokes],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getResultsForTour(tourId) {
- return new Promise((resolve, reject) => {
- db.all(
- `SELECT tr.tour_course_id, tr.pdga_number, tr.total_strokes, tr.recorded_at,
- tc.course_id, tc.layout_id,
- c.name as course_name,
- l.name as layout_name, l.par,
- tp.player_name
- FROM tour_results tr
- JOIN tour_courses tc ON tr.tour_course_id = tc.id
- JOIN courses c ON tc.course_id = c.id
- JOIN layouts l ON tc.layout_id = l.id
- JOIN tour_players tp ON tp.tour_id = tc.tour_id AND tp.pdga_number = tr.pdga_number
- WHERE tc.tour_id = ?
- ORDER BY tc.id ASC, tr.total_strokes ASC`,
- [tourId],
- (err, rows) => {
- if (err) reject(err);
- else resolve(rows);
- }
- );
- });
-}
-
-module.exports = {
- createTour,
- getTourByCode,
- addCourseToTour,
- getTourCourses,
- joinTour,
- getTourPlayers,
- getPlayerInTour,
- recordResult,
- getResultsForTour
-};
-```
-
-- [ ] **Step 2: Verify no syntax errors**
-
-Run: `node -e "require('./src/models/tour')"`
-Expected: No output (clean require)
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/models/tour.js
-git commit -m "feat: add tour model with CRUD operations"
-```
-
----
-
-### Task 3: Tour Service
-
-**Files:**
-- Create: `src/services/tour-service.js`
-
-- [ ] **Step 1: Create tour service**
-
-```javascript
-const crypto = require('crypto');
-const { getTourCourses, getResultsForTour, getTourPlayers } = require('../models/tour');
-
-function generateTourCode() {
- return crypto.randomBytes(3).toString('hex').toUpperCase();
-}
-
-function isTourActive(tour) {
- const now = new Date().toISOString().split('T')[0];
- return now >= tour.start_date && now <= tour.end_date;
-}
-
-function isTourFinished(tour) {
- const now = new Date().toISOString().split('T')[0];
- return now > tour.end_date;
-}
-
-async function calculateLeaderboard(tourId) {
- const courses = await getTourCourses(tourId);
- const results = await getResultsForTour(tourId);
- const players = await getTourPlayers(tourId);
-
- // Group results by course
- const resultsByCourse = {};
- for (const course of courses) {
- resultsByCourse[course.tour_course_id] = results
- .filter(r => r.tour_course_id === course.tour_course_id)
- .sort((a, b) => a.total_strokes - b.total_strokes);
- }
-
- // Calculate points per course
- const playerPoints = {};
- for (const player of players) {
- playerPoints[player.pdga_number] = {
- player_name: player.player_name,
- pdga_number: player.pdga_number,
- courses: {},
- total_points: 0
- };
- }
-
- for (const course of courses) {
- const courseResults = resultsByCourse[course.tour_course_id] || [];
- const numPlayers = courseResults.length;
-
- let rank = 1;
- for (let i = 0; i < courseResults.length; i++) {
- // Check for ties with previous player
- if (i > 0 && courseResults[i].total_strokes > courseResults[i - 1].total_strokes) {
- rank = i + 1;
- }
-
- const points = Math.max(numPlayers - rank + 1, 1);
- const r = courseResults[i];
- const relativePar = r.total_strokes - course.par;
-
- if (playerPoints[r.pdga_number]) {
- playerPoints[r.pdga_number].courses[course.tour_course_id] = {
- total_strokes: r.total_strokes,
- relative_par: relativePar,
- points: points,
- rank: rank
- };
- playerPoints[r.pdga_number].total_points += points;
- }
- }
- }
-
- // Sort by total points descending
- const leaderboard = Object.values(playerPoints)
- .sort((a, b) => b.total_points - a.total_points);
-
- return { courses, leaderboard };
-}
-
-module.exports = {
- generateTourCode,
- isTourActive,
- isTourFinished,
- calculateLeaderboard
-};
-```
-
-- [ ] **Step 2: Verify no syntax errors**
-
-Run: `node -e "require('./src/services/tour-service')"`
-Expected: No output (clean require)
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add src/services/tour-service.js
-git commit -m "feat: add tour service with code generation and leaderboard calculation"
-```
-
----
-
-### Task 4: Tour Routes
-
-**Files:**
-- Create: `src/routes/tours.js`
-- Modify: `server.js`
-- Modify: `src/routes/pages.js`
-
-- [ ] **Step 1: Create tour routes**
-
-```javascript
-const express = require('express');
-const router = express.Router();
-const { getAllCoursesFromDB, getLayoutsForCourse } = require('../models/course');
-const {
- createTour, getTourByCode, addCourseToTour, getTourCourses,
- joinTour, getPlayerInTour, recordResult
-} = require('../models/tour');
-const { generateTourCode, isTourActive, isTourFinished, calculateLeaderboard } = require('../services/tour-service');
-const logger = require('../logger');
-
-// Create a new tour
-router.post('/api/tours', async (req, res) => {
- try {
- const { name, startDate, endDate, courses } = req.body;
-
- if (!name || !startDate || !endDate || !courses || courses.length === 0) {
- return res.status(400).json({ error: 'Name, dates, and at least one course are required' });
- }
-
- if (new Date(endDate) <= new Date(startDate)) {
- return res.status(400).json({ error: 'End date must be after start date' });
- }
-
- const code = generateTourCode();
- const tourId = await createTour(name, code, startDate, endDate);
-
- for (const course of courses) {
- await addCourseToTour(tourId, course.courseId, course.layoutId);
- }
-
- logger.info(`Tour created: "${name}" (${code}) with ${courses.length} courses`);
- res.json({ success: true, code, message: `Tour "${name}" created` });
- } catch (error) {
- logger.error('Error creating tour:', error.message);
- res.status(500).json({ error: 'Failed to create tour' });
- }
-});
-
-// Join a tour
-router.post('/api/tours/:code/join', async (req, res) => {
- try {
- const { code } = req.params;
- const { pdgaNumber, playerName } = req.body;
-
- if (!pdgaNumber || !playerName) {
- return res.status(400).json({ error: 'PDGA number and name are required' });
- }
-
- const tour = await getTourByCode(code);
- if (!tour) {
- return res.status(404).json({ error: 'Tour not found' });
- }
-
- await joinTour(tour.id, pdgaNumber, playerName);
- logger.info(`Player ${playerName} (${pdgaNumber}) joined tour ${code}`);
- res.json({ success: true, message: `Joined tour "${tour.name}"` });
- } catch (error) {
- logger.error('Error joining tour:', error.message);
- res.status(500).json({ error: 'Failed to join tour' });
- }
-});
-
-// Record a result
-router.post('/api/tours/:code/results', async (req, res) => {
- try {
- const { code } = req.params;
- const { pdgaNumber, tourCourseId, totalStrokes } = req.body;
-
- if (!pdgaNumber || !tourCourseId || !totalStrokes) {
- return res.status(400).json({ error: 'PDGA number, course, and strokes are required' });
- }
-
- const tour = await getTourByCode(code);
- if (!tour) {
- return res.status(404).json({ error: 'Tour not found' });
- }
-
- if (!isTourActive(tour)) {
- return res.status(400).json({ error: 'Tour is not active. Results can only be recorded during the tour period.' });
- }
-
- const player = await getPlayerInTour(tour.id, pdgaNumber);
- if (!player) {
- return res.status(403).json({ error: 'You must join the tour before recording results' });
- }
-
- await recordResult(tourCourseId, pdgaNumber, parseInt(totalStrokes));
- logger.info(`Result recorded: ${pdgaNumber} scored ${totalStrokes} on course ${tourCourseId} in tour ${code}`);
- res.json({ success: true, message: 'Result recorded' });
- } catch (error) {
- logger.error('Error recording result:', error.message);
- res.status(500).json({ error: 'Failed to record result' });
- }
-});
-
-// Get leaderboard partial (HTMX)
-router.get('/partials/tour-leaderboard/:code', async (req, res) => {
- try {
- const tour = await getTourByCode(req.params.code);
- if (!tour) {
- return res.status(404).send('
Tour not found
');
- }
-
- const { courses, leaderboard } = await calculateLeaderboard(tour.id);
- res.render('../partials/tour-leaderboard', {
- tour,
- courses,
- leaderboard,
- isActive: isTourActive(tour),
- isFinished: isTourFinished(tour)
- });
- } catch (error) {
- logger.error('Error loading leaderboard:', error.message);
- res.status(500).send('Error loading leaderboard
');
- }
-});
-
-// Get courses and layouts for tour creation form
-router.get('/api/tours/courses-with-layouts', async (req, res) => {
- try {
- const courses = await getAllCoursesFromDB();
- const coursesWithLayouts = [];
-
- for (const course of courses) {
- const layouts = await getLayoutsForCourse(course.id);
- if (layouts.length > 0) {
- coursesWithLayouts.push({ ...course, layouts });
- }
- }
-
- res.json(coursesWithLayouts);
- } catch (error) {
- logger.error('Error fetching courses with layouts:', error.message);
- res.status(500).json({ error: 'Failed to fetch courses' });
- }
-});
-
-// Tour page
-router.get('/tours/:code', async (req, res) => {
- try {
- const tour = await getTourByCode(req.params.code);
- if (!tour) {
- return res.status(404).render('tour', { tour: null });
- }
-
- const courses = await getTourCourses(tour.id);
- res.render('tour', {
- tour,
- courses,
- isActive: isTourActive(tour),
- isFinished: isTourFinished(tour)
- });
- } catch (error) {
- logger.error('Error loading tour page:', error.message);
- res.status(500).send('Error loading tour');
- }
-});
-
-module.exports = router;
-```
-
-- [ ] **Step 2: Add /tours page route to pages.js**
-
-Add before `module.exports`:
-
-```javascript
-router.get('/tours', (req, res) => {
- res.render('tours');
-});
-```
-
-- [ ] **Step 3: Mount tour routes in server.js**
-
-Add after the existing route imports:
-
-```javascript
-const tourRoutes = require('./src/routes/tours');
-```
-
-Add after the existing `app.use()` calls:
-
-```javascript
-app.use(tourRoutes);
-```
-
-- [ ] **Step 4: Add Tours to nav**
-
-In `views/partials/nav.ejs`, add after the Courses link:
-
-```html
-Tours
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add src/routes/tours.js src/routes/pages.js server.js views/partials/nav.ejs
-git commit -m "feat: add tour routes, page route, and nav link"
-```
-
----
-
-### Task 5: Create Tour Page
-
-**Files:**
-- Create: `views/pages/tours.ejs`
-- Create: `public/js/tours.js`
-
-- [ ] **Step 1: Create tours.ejs**
-
-Follow the exact pattern from `views/pages/courses.ejs` — define `body` variable, include layout partial.
-
-```ejs
-<% var body = `
-
-
-
-
Join a Tour
-
-
-
-
-
-
-
-
Tour Created!
-
Share this link with players:
-
-
-`; %>
-
-<%- include('../partials/layout', {
- title: 'Tours - PDGA Ratings',
- heading: 'Tours',
- activePage: 'tours',
- cssFiles: ['tours.css'],
- jsFiles: ['tours.js'],
- body: body
-}) %>
-```
-
-- [ ] **Step 2: Create tours.js**
-
-Use safe DOM methods (createElement, textContent, appendChild) instead of innerHTML for user-controlled content.
-
-```javascript
-let coursesData = [];
-
-async function loadCourses() {
- try {
- const res = await fetch('/api/tours/courses-with-layouts');
- coursesData = await res.json();
- updateCourseDropdowns();
- } catch (err) {
- console.error('Failed to load courses:', err);
- }
-}
-
-function populateSelectWithCourses(select) {
- select.textContent = '';
- var defaultOpt = document.createElement('option');
- defaultOpt.value = '';
- defaultOpt.textContent = 'Select a course...';
- select.appendChild(defaultOpt);
-
- coursesData.forEach(function(course) {
- var opt = document.createElement('option');
- opt.value = course.id;
- opt.textContent = course.name + ' (' + course.city + ')';
- select.appendChild(opt);
- });
-}
-
-function updateCourseDropdowns() {
- document.querySelectorAll('.course-select').forEach(function(select) {
- var currentValue = select.value;
- populateSelectWithCourses(select);
- select.value = currentValue;
- });
-}
-
-function onCourseChange(courseSelect) {
- var index = courseSelect.dataset.index;
- var layoutSelect = document.querySelector('.layout-select[data-index="' + index + '"]');
- var courseId = parseInt(courseSelect.value);
-
- layoutSelect.textContent = '';
- layoutSelect.disabled = true;
-
- var defaultOpt = document.createElement('option');
- defaultOpt.value = '';
- defaultOpt.textContent = 'Select layout...';
- layoutSelect.appendChild(defaultOpt);
-
- if (!courseId) return;
-
- var course = coursesData.find(function(c) { return c.id === courseId; });
- if (!course) return;
-
- course.layouts.forEach(function(layout) {
- var opt = document.createElement('option');
- opt.value = layout.id;
- opt.textContent = layout.name + ' (Par ' + layout.par + ')';
- layoutSelect.appendChild(opt);
- });
- layoutSelect.disabled = false;
-}
-
-var courseIndex = 1;
-
-function addCourseEntry() {
- var container = document.getElementById('course-selector');
- var entry = document.createElement('div');
- entry.className = 'course-entry';
-
- var courseSelect = document.createElement('select');
- courseSelect.className = 'input course-select';
- courseSelect.dataset.index = courseIndex;
- populateSelectWithCourses(courseSelect);
- courseSelect.addEventListener('change', function() { onCourseChange(courseSelect); });
-
- var layoutSelect = document.createElement('select');
- layoutSelect.className = 'input layout-select';
- layoutSelect.dataset.index = courseIndex;
- layoutSelect.disabled = true;
- var defaultOpt = document.createElement('option');
- defaultOpt.value = '';
- defaultOpt.textContent = 'Select course first';
- layoutSelect.appendChild(defaultOpt);
-
- var removeBtn = document.createElement('button');
- removeBtn.className = 'btn-remove';
- removeBtn.type = 'button';
- var icon = document.createElement('i');
- icon.className = 'fas fa-times';
- removeBtn.appendChild(icon);
- removeBtn.addEventListener('click', function() { entry.remove(); });
-
- entry.appendChild(courseSelect);
- entry.appendChild(layoutSelect);
- entry.appendChild(removeBtn);
- container.appendChild(entry);
-
- courseIndex++;
-}
-
-async function createTour() {
- var name = document.getElementById('tour-name').value.trim();
- var startDate = document.getElementById('tour-start').value;
- var endDate = document.getElementById('tour-end').value;
-
- if (!name || !startDate || !endDate) {
- alert('Please fill in name and dates');
- return;
- }
-
- var courses = [];
- document.querySelectorAll('.course-entry').forEach(function(entry) {
- var courseId = entry.querySelector('.course-select').value;
- var layoutId = entry.querySelector('.layout-select').value;
- if (courseId && layoutId) {
- courses.push({ courseId: parseInt(courseId), layoutId: parseInt(layoutId) });
- }
- });
-
- if (courses.length === 0) {
- alert('Please select at least one course with a layout');
- return;
- }
-
- var btn = document.getElementById('create-tour-btn');
- btn.disabled = true;
-
- try {
- var res = await fetch('/api/tours', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: name, startDate: startDate, endDate: endDate, courses: courses })
- });
-
- var data = await res.json();
- if (data.success) {
- var link = document.getElementById('tour-link');
- link.href = '/tours/' + data.code;
- link.textContent = window.location.origin + '/tours/' + data.code;
- document.getElementById('tour-created').style.display = '';
- } else {
- alert(data.error || 'Failed to create tour');
- }
- } catch (err) {
- console.error('Error creating tour:', err);
- alert('Failed to create tour');
- } finally {
- btn.disabled = false;
- }
-}
-
-function goToTour() {
- var code = document.getElementById('tour-code').value.trim().toUpperCase();
- if (code) {
- window.location.href = '/tours/' + code;
- }
-}
-
-// Initialize
-document.addEventListener('DOMContentLoaded', function() {
- loadCourses();
-
- // Delegate change events for course selects
- document.getElementById('course-selector').addEventListener('change', function(e) {
- if (e.target.classList.contains('course-select')) {
- onCourseChange(e.target);
- }
- });
-});
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add views/pages/tours.ejs public/js/tours.js
-git commit -m "feat: add create tour page with course/layout selection"
-```
-
----
-
-### Task 6: Tour View Page
-
-**Files:**
-- Create: `views/pages/tour.ejs`
-- Create: `public/js/tour.js`
-
-- [ ] **Step 1: Create tour.ejs**
-
-```ejs
-<% if (!tour) { %>
- <% var body = `
-
-
Tour Not Found
-
The tour code is invalid or the tour has been removed.
-
Back to Tours
-
- `; %>
- <%- include('../partials/layout', {
- title: 'Tour Not Found',
- heading: 'Tour Not Found',
- activePage: 'tours',
- cssFiles: ['tours.css'],
- body: body
- }) %>
-<% } else { %>
- <% var statusBadge = isActive
- ? 'Active'
- : isFinished
- ? 'Finished'
- : 'Upcoming'; %>
-
- <% var courseOptions = courses.map(function(c) {
- return '';
- }).join(''); %>
-
- <% var body = `
-
-
-
-
-
-
Record Result
-
-
-
-
-
-
-
-
-
Loading leaderboard...
-
- `; %>
-
- <%- include('../partials/layout', {
- title: tour.name + ' - PDGA Tours',
- heading: tour.name,
- activePage: 'tours',
- cssFiles: ['tours.css'],
- jsFiles: ['tour.js'],
- initScript: 'initTour("' + tour.code + '");',
- body: body
- }) %>
-<% } %>
-```
-
-- [ ] **Step 2: Create tour.js**
-
-```javascript
-var currentPdgaNumber = null;
-
-function initTour(code) {
- // Check if player already joined (stored in sessionStorage)
- var stored = sessionStorage.getItem('tour_' + code);
- if (stored) {
- var data = JSON.parse(stored);
- currentPdgaNumber = data.pdgaNumber;
- showResultSection();
- }
-}
-
-function showResultSection() {
- var joinSection = document.getElementById('join-section');
- var resultSection = document.getElementById('result-section');
- if (joinSection) joinSection.style.display = 'none';
- if (resultSection) resultSection.style.display = '';
-}
-
-async function joinTour() {
- var pdgaNumber = document.getElementById('pdga-number').value.trim();
- var playerName = document.getElementById('player-name').value.trim();
-
- if (!pdgaNumber || !playerName) {
- alert('Please enter your PDGA number and name');
- return;
- }
-
- var code = window.location.pathname.split('/').pop();
-
- try {
- var res = await fetch('/api/tours/' + code + '/join', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName })
- });
-
- var data = await res.json();
- if (data.success) {
- currentPdgaNumber = pdgaNumber;
- sessionStorage.setItem('tour_' + code, JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName }));
- showResultSection();
- // Refresh leaderboard
- htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
- } else {
- alert(data.error || 'Failed to join tour');
- }
- } catch (err) {
- console.error('Error joining tour:', err);
- alert('Failed to join tour');
- }
-}
-
-async function recordResult(code) {
- var tourCourseId = document.getElementById('result-course').value;
- var totalStrokes = document.getElementById('result-strokes').value;
-
- if (!tourCourseId || !totalStrokes) {
- alert('Please select a course and enter your strokes');
- return;
- }
-
- if (!currentPdgaNumber) {
- alert('Please join the tour first');
- return;
- }
-
- try {
- var res = await fetch('/api/tours/' + code + '/results', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- pdgaNumber: currentPdgaNumber,
- tourCourseId: parseInt(tourCourseId),
- totalStrokes: parseInt(totalStrokes)
- })
- });
-
- var data = await res.json();
- if (data.success) {
- document.getElementById('result-strokes').value = '';
- // Refresh leaderboard
- htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
- } else {
- alert(data.error || 'Failed to record result');
- }
- } catch (err) {
- console.error('Error recording result:', err);
- alert('Failed to record result');
- }
-}
-```
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add views/pages/tour.ejs public/js/tour.js
-git commit -m "feat: add tour view page with join form and result recording"
-```
-
----
-
-### Task 7: Leaderboard Partial
-
-**Files:**
-- Create: `views/partials/tour-leaderboard.ejs`
-
-- [ ] **Step 1: Create tour-leaderboard.ejs**
-
-```ejs
-<% if (leaderboard.length === 0) { %>
-
-
No players have joined yet. Share the tour link to get started!
-
-<% } else { %>
-
-
Leaderboard
-
-
-
- | # |
- Player |
- <% courses.forEach(function(c) { %>
- <%= c.course_name %> <%= c.layout_name %> |
- <% }); %>
- Points |
-
-
-
- <% var rank = 1; %>
- <% leaderboard.forEach(function(player, i) { %>
- <% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %>
-
- | <%= rank %> |
-
- <%= player.player_name %>
- <%= player.pdga_number %>
- |
- <% courses.forEach(function(c) { %>
-
- <% var result = player.courses[c.tour_course_id]; %>
- <% if (result) { %>
- <%= result.total_strokes %>
- <% if (result.relative_par > 0) { %>
- (+<%= result.relative_par %>)
- <% } else if (result.relative_par < 0) { %>
- (<%= result.relative_par %>)
- <% } else { %>
- (E)
- <% } %>
- <% } else { %>
- -
- <% } %>
- |
- <% }); %>
- <%= player.total_points %> |
-
- <% }); %>
-
-
-
-<% } %>
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add views/partials/tour-leaderboard.ejs
-git commit -m "feat: add tour leaderboard HTMX partial with par display"
-```
-
----
-
-### Task 8: Styling
-
-**Files:**
-- Create: `public/css/tours.css`
-
-- [ ] **Step 1: Create tours.css**
-
-Use the existing design system variables from `shared.css`. Style the tour-specific elements.
-
-```css
-/* Tour Form */
-
-.tour-form {
- display: flex;
- flex-direction: column;
- gap: 16px;
- max-width: 600px;
- margin: 0 auto;
-}
-
-.form-group {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.form-group label {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.form-row {
- display: flex;
- gap: 12px;
-}
-
-.form-row .form-group {
- flex: 1;
-}
-
-/* Course Selector */
-
-.course-entry {
- display: flex;
- gap: 8px;
- align-items: center;
- margin-bottom: 8px;
-}
-
-.course-entry select {
- flex: 1;
-}
-
-.btn-secondary {
- background: var(--surface-2);
- color: var(--text-secondary);
- border: 1px solid var(--border);
-}
-
-.btn-secondary:hover {
- background: var(--surface-3);
- color: var(--text-primary);
-}
-
-.btn-remove {
- background: none;
- border: none;
- color: var(--text-muted);
- cursor: pointer;
- padding: 6px;
- border-radius: var(--radius-sm);
- font-size: 14px;
- transition: color var(--transition), background var(--transition);
-}
-
-.btn-remove:hover {
- color: var(--red);
- background: var(--red-subtle);
-}
-
-/* Tour Header */
-
-.tour-header {
- margin-bottom: 24px;
-}
-
-.tour-meta {
- display: flex;
- align-items: center;
- gap: 16px;
- flex-wrap: wrap;
-}
-
-.tour-dates,
-.tour-code-label {
- font-size: 14px;
- color: var(--text-secondary);
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-/* Badges */
-
-.badge {
- display: inline-block;
- padding: 3px 10px;
- border-radius: 999px;
- font-size: 12px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.badge-active {
- background: var(--green-subtle);
- color: var(--green);
-}
-
-.badge-finished {
- background: var(--surface-3);
- color: var(--text-secondary);
-}
-
-.badge-upcoming {
- background: var(--accent-subtle);
- color: var(--accent);
-}
-
-/* Tour Code Display */
-
-.tour-created-message {
- text-align: center;
- color: var(--text-secondary);
- margin: 0 0 12px 0;
-}
-
-.tour-code-display {
- text-align: center;
- padding: 16px;
- background: var(--surface-2);
- border-radius: var(--radius-md);
- border: 1px solid var(--border);
-}
-
-.tour-code-display a {
- font-family: var(--font-mono);
- font-size: 16px;
- font-weight: 500;
-}
-
-/* Leaderboard */
-
-.strokes {
- font-weight: 600;
- font-family: var(--font-mono);
-}
-
-.under-par {
- color: var(--green);
- font-size: 12px;
- font-weight: 600;
-}
-
-.over-par {
- color: var(--red);
- font-size: 12px;
- font-weight: 600;
-}
-
-.even-par {
- color: var(--text-muted);
- font-size: 12px;
- font-weight: 600;
-}
-
-/* Responsive */
-
-@media (max-width: 768px) {
- .form-row {
- flex-direction: column;
- }
-
- .course-entry {
- flex-direction: column;
- }
-
- .course-entry select {
- width: 100%;
- }
-
- .tour-meta {
- gap: 8px;
- }
-}
-```
-
-- [ ] **Step 2: Commit**
-
-```bash
-git add public/css/tours.css
-git commit -m "feat: add tour styling using existing design system"
-```
-
----
-
-### Task 9: Integration Verification
-
-- [ ] **Step 1: Start the server**
-
-Run: `npm run dev`
-Expected: Server starts, database initializes with new tables, no errors.
-
-- [ ] **Step 2: Verify navigation**
-
-Navigate to `http://localhost:3000` — "Tours" link should appear in nav.
-
-- [ ] **Step 3: Verify tour creation flow**
-
-1. Go to `/tours`
-2. Fill in tour name, dates, select a course + layout
-3. Click "Create Tour"
-4. Verify tour code and link appear
-
-- [ ] **Step 4: Verify join + results flow**
-
-1. Navigate to the tour URL
-2. Enter PDGA number and name, click Join
-3. Select a course, enter strokes, click Save
-4. Verify leaderboard updates
-
-- [ ] **Step 5: Final commit if any fixes needed**
-
-```bash
-git add -A
-git commit -m "fix: integration fixes for async tours"
-```
diff --git a/public/css/courses.css b/public/css/courses.css
index 8ff8ed7..ffc47b6 100644
--- a/public/css/courses.css
+++ b/public/css/courses.css
@@ -132,36 +132,3 @@
border-style: dashed;
}
-/* ── Tjing Import ────────────────────────────── */
-
-.tjing-result {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 12px 14px;
- margin: 6px 0;
- background: var(--surface-2);
- border-radius: var(--radius-sm);
- border: 1px solid var(--border);
-}
-
-.tjing-result-info {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.tjing-result-name {
- font-weight: 600;
- font-size: 14px;
- color: var(--text-primary);
-}
-
-.tjing-result-address {
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-#tjing-results {
- margin-top: 12px;
-}
diff --git a/public/css/tours.css b/public/css/tours.css
deleted file mode 100644
index 5b819b5..0000000
--- a/public/css/tours.css
+++ /dev/null
@@ -1,211 +0,0 @@
-/* Tour Form */
-
-.tour-form {
- display: flex;
- flex-direction: column;
- gap: 16px;
- max-width: 600px;
- margin: 0 auto;
-}
-
-.form-group {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.form-group label {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.form-row {
- display: flex;
- gap: 12px;
-}
-
-.form-row .form-group {
- flex: 1;
-}
-
-/* Course Selector */
-
-.course-entry {
- display: flex;
- gap: 8px;
- align-items: center;
- margin-bottom: 8px;
- flex-wrap: wrap;
-}
-
-.course-entry select {
- flex: 1;
- min-width: 0;
-}
-
-.manual-layout {
- display: flex;
- gap: 6px;
- align-items: center;
- flex-basis: 100%;
-}
-
-.manual-layout .layout-name-input {
- flex: 1;
-}
-
-.manual-layout .layout-par-input {
- width: 80px;
- flex: none;
-}
-
-.btn-secondary {
- background: var(--surface-2);
- color: var(--text-secondary);
- border: 1px solid var(--border);
-}
-
-.btn-secondary:hover {
- background: var(--surface-3);
- color: var(--text-primary);
-}
-
-.btn-remove {
- background: none;
- border: none;
- color: var(--text-muted);
- cursor: pointer;
- padding: 6px;
- border-radius: var(--radius-sm);
- font-size: 14px;
- transition: color var(--transition), background var(--transition);
-}
-
-.btn-remove:hover {
- color: var(--red);
- background: var(--red-subtle);
-}
-
-/* Tour Header */
-
-.tour-header {
- margin-bottom: 24px;
-}
-
-.tour-meta {
- display: flex;
- align-items: center;
- gap: 16px;
- flex-wrap: wrap;
-}
-
-.tour-dates,
-.tour-code-label {
- font-size: 14px;
- color: var(--text-secondary);
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-/* Badges */
-
-.badge {
- display: inline-block;
- padding: 3px 10px;
- border-radius: 999px;
- font-size: 12px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.badge-active {
- background: var(--green-subtle);
- color: var(--green);
-}
-
-.badge-finished {
- background: var(--surface-3);
- color: var(--text-secondary);
-}
-
-.badge-upcoming {
- background: var(--accent-subtle);
- color: var(--accent);
-}
-
-/* Tour Code Display */
-
-.tour-created-message {
- text-align: center;
- color: var(--text-secondary);
- margin: 0 0 12px 0;
-}
-
-.tour-code-display {
- text-align: center;
- padding: 16px;
- background: var(--surface-2);
- border-radius: var(--radius-md);
- border: 1px solid var(--border);
-}
-
-.tour-code-display a {
- font-family: var(--font-mono);
- font-size: 16px;
- font-weight: 500;
-}
-
-/* Leaderboard */
-
-.strokes {
- font-weight: 600;
- font-family: var(--font-mono);
-}
-
-.under-par {
- color: var(--green);
- font-size: 12px;
- font-weight: 600;
-}
-
-.over-par {
- color: var(--red);
- font-size: 12px;
- font-weight: 600;
-}
-
-.even-par {
- color: var(--text-muted);
- font-size: 12px;
- font-weight: 600;
-}
-
-/* Responsive */
-
-@media (max-width: 768px) {
- .form-row {
- flex-direction: column;
- }
-
- .course-entry {
- flex-direction: column;
- }
-
- .course-entry select,
- .manual-layout {
- width: 100%;
- }
-
- .manual-layout {
- flex-direction: row;
- }
-
- .tour-meta {
- gap: 8px;
- }
-}
\ No newline at end of file
diff --git a/public/js/courses.js b/public/js/courses.js
index 7ee7990..ef557eb 100644
--- a/public/js/courses.js
+++ b/public/js/courses.js
@@ -54,88 +54,6 @@ async function scrapeCourses() {
}
}
-async function searchTjing() {
- var input = document.getElementById('tjing-search');
- var query = input.value.trim();
- if (query.length < 2) return;
-
- var btn = document.getElementById('tjing-search-btn');
- btn.disabled = true;
-
- try {
- var response = await fetch('/api/tjing/search?q=' + encodeURIComponent(query));
- var courses = await response.json();
- var container = document.getElementById('tjing-results');
- container.textContent = '';
-
- if (courses.length === 0) {
- var p = document.createElement('p');
- p.className = 'no-layouts';
- p.textContent = 'No courses found on Tjing';
- container.appendChild(p);
- return;
- }
-
- courses.forEach(function(course) {
- var item = document.createElement('div');
- item.className = 'tjing-result';
-
- var info = document.createElement('div');
- info.className = 'tjing-result-info';
-
- var name = document.createElement('span');
- name.className = 'tjing-result-name';
- name.textContent = course.name;
-
- var addr = document.createElement('span');
- addr.className = 'tjing-result-address';
- addr.textContent = course.address || '';
-
- info.appendChild(name);
- info.appendChild(addr);
-
- var importBtn = document.createElement('button');
- importBtn.className = 'btn';
- importBtn.textContent = 'Import';
- importBtn.addEventListener('click', function() { importFromTjing(course.id, importBtn); });
-
- item.appendChild(info);
- item.appendChild(importBtn);
- container.appendChild(item);
- });
- } catch (error) {
- console.error('Error searching Tjing:', error);
- alert('Failed to search Tjing');
- } finally {
- btn.disabled = false;
- }
-}
-
-async function importFromTjing(tjingId, btn) {
- btn.disabled = true;
- btn.textContent = 'Importing...';
-
- try {
- var response = await fetch('/api/tjing/import/' + tjingId, { method: 'POST' });
- var data = await response.json();
-
- if (data.success) {
- btn.textContent = 'Imported!';
- btn.style.background = 'var(--green)';
- htmx.ajax('GET', '/partials/course-table', '#courses-table');
- } else {
- alert(data.error || 'Failed to import');
- btn.disabled = false;
- btn.textContent = 'Import';
- }
- } catch (error) {
- console.error('Error importing from Tjing:', error);
- alert('Failed to import course');
- btn.disabled = false;
- btn.textContent = 'Import';
- }
-}
-
async function scrapeLayouts(courseId, courseName) {
const icon = document.querySelector(`#row-${courseId} .refresh-icon`);
icon.classList.add('spinning');
diff --git a/public/js/tour.js b/public/js/tour.js
deleted file mode 100644
index 83673a3..0000000
--- a/public/js/tour.js
+++ /dev/null
@@ -1,91 +0,0 @@
-var currentPdgaNumber = null;
-
-function initTour(code) {
- // Check if player already joined (stored in localStorage)
- var stored = localStorage.getItem('tour_' + code);
- if (stored) {
- var data = JSON.parse(stored);
- currentPdgaNumber = data.pdgaNumber;
- showResultSection();
- }
-}
-
-function showResultSection() {
- var joinSection = document.getElementById('join-section');
- var resultSection = document.getElementById('result-section');
- if (joinSection) joinSection.style.display = 'none';
- if (resultSection) resultSection.style.display = '';
-}
-
-async function joinTour() {
- var pdgaNumber = document.getElementById('pdga-number').value.trim();
- var playerName = document.getElementById('player-name').value.trim();
-
- if (!pdgaNumber || !playerName) {
- alert('Please enter your PDGA number and name');
- return;
- }
-
- var code = window.location.pathname.split('/').pop();
-
- try {
- var res = await fetch('/api/tours/' + code + '/join', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName })
- });
-
- var data = await res.json();
- if (data.success) {
- currentPdgaNumber = pdgaNumber;
- localStorage.setItem('tour_' + code, JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName }));
- showResultSection();
- // Refresh leaderboard
- htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
- } else {
- alert(data.error || 'Failed to join tour');
- }
- } catch (err) {
- console.error('Error joining tour:', err);
- alert('Failed to join tour');
- }
-}
-
-async function recordResult(code) {
- var tourCourseId = document.getElementById('result-course').value;
- var totalStrokes = document.getElementById('result-strokes').value;
-
- if (!tourCourseId || !totalStrokes) {
- alert('Please select a course and enter your strokes');
- return;
- }
-
- if (!currentPdgaNumber) {
- alert('Please join the tour first');
- return;
- }
-
- try {
- var res = await fetch('/api/tours/' + code + '/results', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- pdgaNumber: currentPdgaNumber,
- tourCourseId: parseInt(tourCourseId),
- totalStrokes: parseInt(totalStrokes)
- })
- });
-
- var data = await res.json();
- if (data.success) {
- document.getElementById('result-strokes').value = '';
- // Refresh leaderboard
- htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
- } else {
- alert(data.error || 'Failed to record result');
- }
- } catch (err) {
- console.error('Error recording result:', err);
- alert('Failed to record result');
- }
-}
\ No newline at end of file
diff --git a/public/js/tours.js b/public/js/tours.js
deleted file mode 100644
index 93f209d..0000000
--- a/public/js/tours.js
+++ /dev/null
@@ -1,255 +0,0 @@
-var coursesData = [];
-
-async function loadCourses() {
- try {
- var res = await fetch('/api/tours/courses-with-layouts');
- coursesData = await res.json();
- updateCourseDropdowns();
- } catch (err) {
- console.error('Failed to load courses:', err);
- }
-}
-
-function populateSelectWithCourses(select) {
- select.textContent = '';
- var defaultOpt = document.createElement('option');
- defaultOpt.value = '';
- defaultOpt.textContent = 'Select a course...';
- select.appendChild(defaultOpt);
-
- coursesData.forEach(function(course) {
- var opt = document.createElement('option');
- opt.value = course.id;
- opt.textContent = course.name + ' (' + course.city + ')';
- select.appendChild(opt);
- });
-}
-
-function updateCourseDropdowns() {
- document.querySelectorAll('.course-select').forEach(function(select) {
- var currentValue = select.value;
- populateSelectWithCourses(select);
- select.value = currentValue;
- });
-}
-
-function onCourseChange(courseSelect) {
- var index = courseSelect.dataset.index;
- var layoutSelect = courseSelect.parentElement.querySelector('.layout-select');
- var manualFields = courseSelect.parentElement.querySelector('.manual-layout');
- var courseId = parseInt(courseSelect.value);
-
- layoutSelect.textContent = '';
- layoutSelect.disabled = true;
-
- if (manualFields) {
- manualFields.style.display = 'none';
- }
-
- var defaultOpt = document.createElement('option');
- defaultOpt.value = '';
- defaultOpt.textContent = 'Select layout...';
- layoutSelect.appendChild(defaultOpt);
-
- if (!courseId) return;
-
- var course = coursesData.find(function(c) { return c.id === courseId; });
- if (!course) return;
-
- if (course.layouts.length > 0) {
- course.layouts.forEach(function(layout) {
- var opt = document.createElement('option');
- opt.value = layout.id;
- opt.textContent = layout.name + ' (Par ' + layout.par + ')';
- layoutSelect.appendChild(opt);
- });
-
- // Add "custom" option at the end
- var customOpt = document.createElement('option');
- customOpt.value = 'custom';
- customOpt.textContent = '+ Add custom layout...';
- layoutSelect.appendChild(customOpt);
- } else {
- // No layouts — show "custom" as the only real option
- var customOpt = document.createElement('option');
- customOpt.value = 'custom';
- customOpt.textContent = '+ Add custom layout...';
- layoutSelect.appendChild(customOpt);
- }
-
- layoutSelect.disabled = false;
-}
-
-function onLayoutChange(layoutSelect) {
- var manualFields = layoutSelect.parentElement.querySelector('.manual-layout');
- if (layoutSelect.value === 'custom') {
- if (manualFields) manualFields.style.display = '';
- } else {
- if (manualFields) manualFields.style.display = 'none';
- }
-}
-
-function createManualLayoutFields() {
- var div = document.createElement('div');
- div.className = 'manual-layout';
- div.style.display = 'none';
-
- var nameInput = document.createElement('input');
- nameInput.type = 'text';
- nameInput.className = 'input layout-name-input';
- nameInput.placeholder = 'Layout name';
-
- var parInput = document.createElement('input');
- parInput.type = 'number';
- parInput.className = 'input layout-par-input';
- parInput.placeholder = 'Par';
- parInput.min = '1';
- parInput.style.width = '80px';
-
- div.appendChild(nameInput);
- div.appendChild(parInput);
- return div;
-}
-
-var courseIndex = 1;
-
-function createCourseEntry(index) {
- var entry = document.createElement('div');
- entry.className = 'course-entry';
-
- var courseSelect = document.createElement('select');
- courseSelect.className = 'input course-select';
- courseSelect.dataset.index = index;
- populateSelectWithCourses(courseSelect);
- courseSelect.addEventListener('change', function() { onCourseChange(courseSelect); });
-
- var layoutSelect = document.createElement('select');
- layoutSelect.className = 'input layout-select';
- layoutSelect.dataset.index = index;
- layoutSelect.disabled = true;
- var defaultOpt = document.createElement('option');
- defaultOpt.value = '';
- defaultOpt.textContent = 'Select course first';
- layoutSelect.appendChild(defaultOpt);
- layoutSelect.addEventListener('change', function() { onLayoutChange(layoutSelect); });
-
- var manualFields = createManualLayoutFields();
-
- entry.appendChild(courseSelect);
- entry.appendChild(layoutSelect);
- entry.appendChild(manualFields);
-
- return entry;
-}
-
-function addCourseEntry() {
- var container = document.getElementById('course-selector');
- var entry = createCourseEntry(courseIndex);
-
- var removeBtn = document.createElement('button');
- removeBtn.className = 'btn-remove';
- removeBtn.type = 'button';
- var icon = document.createElement('i');
- icon.className = 'fas fa-times';
- removeBtn.appendChild(icon);
- removeBtn.addEventListener('click', function() { entry.remove(); });
-
- entry.appendChild(removeBtn);
- container.appendChild(entry);
-
- courseIndex++;
-}
-
-async function createTour() {
- var name = document.getElementById('tour-name').value.trim();
- var startDate = document.getElementById('tour-start').value;
- var endDate = document.getElementById('tour-end').value;
-
- if (!name || !startDate || !endDate) {
- alert('Please fill in name and dates');
- return;
- }
-
- var courses = [];
- var valid = true;
- document.querySelectorAll('.course-entry').forEach(function(entry) {
- var courseId = entry.querySelector('.course-select').value;
- var layoutSelect = entry.querySelector('.layout-select');
- var layoutValue = layoutSelect.value;
-
- if (!courseId) return;
-
- if (layoutValue === 'custom') {
- var layoutName = entry.querySelector('.layout-name-input').value.trim();
- var par = entry.querySelector('.layout-par-input').value;
- if (!layoutName || !par) {
- alert('Please fill in layout name and par for custom layouts');
- valid = false;
- return;
- }
- courses.push({ courseId: parseInt(courseId), layoutName: layoutName, par: parseInt(par) });
- } else if (layoutValue) {
- courses.push({ courseId: parseInt(courseId), layoutId: parseInt(layoutValue) });
- }
- });
-
- if (!valid) return;
-
- if (courses.length === 0) {
- alert('Please select at least one course with a layout');
- return;
- }
-
- var btn = document.getElementById('create-tour-btn');
- btn.disabled = true;
-
- try {
- var res = await fetch('/api/tours', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name: name, startDate: startDate, endDate: endDate, courses: courses })
- });
-
- var data = await res.json();
- if (data.success) {
- var link = document.getElementById('tour-link');
- link.href = '/tours/' + data.code;
- link.textContent = window.location.origin + '/tours/' + data.code;
- document.getElementById('tour-created').style.display = '';
- } else {
- alert(data.error || 'Failed to create tour');
- }
- } catch (err) {
- console.error('Error creating tour:', err);
- alert('Failed to create tour');
- } finally {
- btn.disabled = false;
- }
-}
-
-function goToTour() {
- var code = document.getElementById('tour-code').value.trim().toUpperCase();
- if (code) {
- window.location.href = '/tours/' + code;
- }
-}
-
-// Initialize
-document.addEventListener('DOMContentLoaded', function() {
- loadCourses();
-
- // Replace the initial static course entry with a dynamic one
- var container = document.getElementById('course-selector');
- container.textContent = '';
- container.appendChild(createCourseEntry(0));
-
- // Delegate change events
- container.addEventListener('change', function(e) {
- if (e.target.classList.contains('course-select')) {
- onCourseChange(e.target);
- }
- if (e.target.classList.contains('layout-select')) {
- onLayoutChange(e.target);
- }
- });
-});
diff --git a/scripts/repair-layouts.js b/scripts/repair-layouts.js
deleted file mode 100644
index af75170..0000000
--- a/scripts/repair-layouts.js
+++ /dev/null
@@ -1,145 +0,0 @@
-#!/usr/bin/env node
-
-// Repairs orphaned layouts by reassigning them to the correct course.
-//
-// The problem: saveCourseToDB used INSERT OR REPLACE which deletes and
-// re-inserts courses with new IDs. Layouts still reference the old IDs.
-//
-// Strategy: For each orphaned layout, find a course that has a matching
-// layout (same name + par) from a valid course_id. If no match, try to
-// find the course by looking at the gap between old and new course IDs
-// (courses were likely re-scraped in the same order).
-
-const path = require('path');
-const dbPath = process.env.DB_PATH || './ratings.db';
-const sqlite3 = require('sqlite3').verbose();
-const db = new sqlite3.Database(dbPath);
-
-function all(sql, params) {
- return new Promise((resolve, reject) => {
- db.all(sql, params || [], (err, rows) => {
- if (err) reject(err);
- else resolve(rows);
- });
- });
-}
-
-function run(sql, params) {
- return new Promise((resolve, reject) => {
- db.run(sql, params || [], function(err) {
- if (err) reject(err);
- else resolve(this.changes);
- });
- });
-}
-
-async function repair() {
- // Find all orphaned layouts (course_id not in courses table)
- const orphaned = await all(`
- SELECT l.id, l.course_id, l.name, l.par, l.mean_rating, l.rating_count, l.last_calculated, l.last_played
- FROM layouts l
- LEFT JOIN courses c ON l.course_id = c.id
- WHERE c.id IS NULL
- `);
-
- console.log('Orphaned layouts:', orphaned.length);
-
- if (orphaned.length === 0) {
- console.log('Nothing to repair!');
- process.exit(0);
- }
-
- // Get all valid courses
- const courses = await all('SELECT id, name, link FROM courses ORDER BY id');
- console.log('Valid courses:', courses.length);
-
- // Get all valid layouts (to avoid duplicates)
- const validLayouts = await all(`
- SELECT l.course_id, l.name, l.par
- FROM layouts l
- JOIN courses c ON l.course_id = c.id
- `);
-
- const validSet = new Set(validLayouts.map(l => l.course_id + '|' + l.name + '|' + l.par));
-
- // Group orphaned layouts by old course_id
- const byOldId = {};
- for (const l of orphaned) {
- if (!byOldId[l.course_id]) byOldId[l.course_id] = [];
- byOldId[l.course_id].push(l);
- }
-
- console.log('Unique orphaned course_ids:', Object.keys(byOldId).length);
-
- // Try to match: old course_ids likely map to current courses
- // If courses were re-scraped in order, old_id and new_id have a fixed offset
- // Let's try to find the offset by checking if shifting all old_ids by some value matches existing courses
- const oldIds = Object.keys(byOldId).map(Number).sort((a, b) => a - b);
- const courseIds = courses.map(c => c.id);
- const courseIdSet = new Set(courseIds);
-
- // Try different offsets
- let bestOffset = 0;
- let bestMatches = 0;
-
- for (let offset = -1000; offset <= 1000; offset++) {
- let matches = 0;
- for (const oldId of oldIds) {
- if (courseIdSet.has(oldId + offset)) matches++;
- }
- if (matches > bestMatches) {
- bestMatches = matches;
- bestOffset = offset;
- }
- }
-
- console.log('Best offset:', bestOffset, '(matches', bestMatches, 'of', oldIds.length, 'orphaned course_ids)');
-
- let repaired = 0;
- let skippedDuplicate = 0;
- let noMatch = 0;
-
- for (const oldId of oldIds) {
- const newId = oldId + bestOffset;
- const layouts = byOldId[oldId];
-
- if (!courseIdSet.has(newId)) {
- noMatch += layouts.length;
- continue;
- }
-
- for (const layout of layouts) {
- const key = newId + '|' + layout.name + '|' + layout.par;
- if (validSet.has(key)) {
- // Duplicate — delete the orphaned one
- await run('DELETE FROM layouts WHERE id = ?', [layout.id]);
- skippedDuplicate++;
- } else {
- // Reassign to correct course
- await run('UPDATE layouts SET course_id = ? WHERE id = ?', [newId, layout.id]);
- validSet.add(key);
- repaired++;
- }
- }
- }
-
- console.log('\nResults:');
- console.log(' Repaired:', repaired);
- console.log(' Deleted (duplicates):', skippedDuplicate);
- console.log(' No match found:', noMatch);
-
- // Verify
- const remaining = await all(`
- SELECT COUNT(*) as c FROM layouts l
- LEFT JOIN courses c ON l.course_id = c.id
- WHERE c.id IS NULL
- `);
- console.log(' Remaining orphans:', remaining[0].c);
-
- process.exit(0);
-}
-
-repair().catch(err => {
- console.error('Error:', err);
- process.exit(1);
-});
diff --git a/server.js b/server.js
index b246885..7747255 100644
--- a/server.js
+++ b/server.js
@@ -4,7 +4,6 @@ const path = require('path');
const { initializeDatabase, checkAndPopulateDatabase } = require('./src/db');
const playerRoutes = require('./src/routes/players');
const courseRoutes = require('./src/routes/courses');
-const tourRoutes = require('./src/routes/tours');
const pageRoutes = require('./src/routes/pages');
const logger = require('./src/logger');
@@ -18,7 +17,6 @@ app.use(express.json());
app.use(playerRoutes);
app.use(courseRoutes);
-app.use(tourRoutes);
app.use(pageRoutes);
initializeDatabase().then(async () => {
diff --git a/src/db.js b/src/db.js
index fc2453f..ed781fa 100644
--- a/src/db.js
+++ b/src/db.js
@@ -92,54 +92,6 @@ function initializeDatabase() {
)
`);
- db.run(`
- CREATE TABLE IF NOT EXISTS tours (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL,
- code TEXT NOT NULL UNIQUE,
- start_date DATE NOT NULL,
- end_date DATE NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- )
- `);
-
- db.run(`
- CREATE TABLE IF NOT EXISTS tour_courses (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tour_id INTEGER NOT NULL,
- course_id INTEGER NOT NULL,
- layout_id INTEGER NOT NULL,
- FOREIGN KEY (tour_id) REFERENCES tours(id),
- FOREIGN KEY (course_id) REFERENCES courses(id),
- FOREIGN KEY (layout_id) REFERENCES layouts(id),
- UNIQUE(tour_id, layout_id)
- )
- `);
-
- db.run(`
- CREATE TABLE IF NOT EXISTS tour_players (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tour_id INTEGER NOT NULL,
- pdga_number TEXT NOT NULL,
- player_name TEXT NOT NULL,
- joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(tour_id, pdga_number),
- FOREIGN KEY (tour_id) REFERENCES tours(id)
- )
- `);
-
- db.run(`
- CREATE TABLE IF NOT EXISTS tour_results (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tour_course_id INTEGER NOT NULL,
- pdga_number TEXT NOT NULL,
- total_strokes INTEGER NOT NULL,
- recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(tour_course_id, pdga_number),
- FOREIGN KEY (tour_course_id) REFERENCES tour_courses(id)
- )
- `);
-
db.run(`
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/src/models/course.js b/src/models/course.js
index 748c8a8..48c73a0 100644
--- a/src/models/course.js
+++ b/src/models/course.js
@@ -70,33 +70,10 @@ 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/models/tour.js b/src/models/tour.js
deleted file mode 100644
index bf2bb60..0000000
--- a/src/models/tour.js
+++ /dev/null
@@ -1,164 +0,0 @@
-const { db } = require('../db');
-
-function createTour(name, code, startDate, endDate) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT INTO tours (name, code, start_date, end_date) VALUES (?, ?, ?, ?)`,
- [name, code, startDate, endDate],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getTourByCode(code) {
- return new Promise((resolve, reject) => {
- db.get(
- 'SELECT * FROM tours WHERE code = ?',
- [code],
- (err, row) => {
- if (err) reject(err);
- else resolve(row);
- }
- );
- });
-}
-
-function addCourseToTour(tourId, courseId, layoutId) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT INTO tour_courses (tour_id, course_id, layout_id) VALUES (?, ?, ?)`,
- [tourId, courseId, layoutId],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getTourCourses(tourId) {
- return new Promise((resolve, reject) => {
- db.all(
- `SELECT tc.id as tour_course_id, tc.course_id, tc.layout_id,
- c.name as course_name, c.city,
- l.name as layout_name, l.par
- FROM tour_courses tc
- JOIN courses c ON tc.course_id = c.id
- JOIN layouts l ON tc.layout_id = l.id
- WHERE tc.tour_id = ?
- ORDER BY c.name ASC`,
- [tourId],
- (err, rows) => {
- if (err) reject(err);
- else resolve(rows);
- }
- );
- });
-}
-
-function getTourCourseById(tourCourseId) {
- return new Promise((resolve, reject) => {
- db.get(
- 'SELECT * FROM tour_courses WHERE id = ?',
- [tourCourseId],
- (err, row) => {
- if (err) reject(err);
- else resolve(row);
- }
- );
- });
-}
-
-function joinTour(tourId, pdgaNumber, playerName) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT OR IGNORE INTO tour_players (tour_id, pdga_number, player_name) VALUES (?, ?, ?)`,
- [tourId, pdgaNumber, playerName],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getTourPlayers(tourId) {
- return new Promise((resolve, reject) => {
- db.all(
- `SELECT * FROM tour_players WHERE tour_id = ? ORDER BY joined_at ASC`,
- [tourId],
- (err, rows) => {
- if (err) reject(err);
- else resolve(rows);
- }
- );
- });
-}
-
-function getPlayerInTour(tourId, pdgaNumber) {
- return new Promise((resolve, reject) => {
- db.get(
- `SELECT * FROM tour_players WHERE tour_id = ? AND pdga_number = ?`,
- [tourId, pdgaNumber],
- (err, row) => {
- if (err) reject(err);
- else resolve(row);
- }
- );
- });
-}
-
-function recordResult(tourCourseId, pdgaNumber, totalStrokes) {
- return new Promise((resolve, reject) => {
- db.run(
- `INSERT INTO tour_results (tour_course_id, pdga_number, total_strokes)
- VALUES (?, ?, ?)
- ON CONFLICT(tour_course_id, pdga_number) DO UPDATE SET total_strokes = excluded.total_strokes, recorded_at = CURRENT_TIMESTAMP`,
- [tourCourseId, pdgaNumber, totalStrokes],
- function(err) {
- if (err) reject(err);
- else resolve(this.lastID);
- }
- );
- });
-}
-
-function getResultsForTour(tourId) {
- return new Promise((resolve, reject) => {
- db.all(
- `SELECT tr.tour_course_id, tr.pdga_number, tr.total_strokes, tr.recorded_at,
- tc.course_id, tc.layout_id,
- c.name as course_name,
- l.name as layout_name, l.par,
- tp.player_name
- FROM tour_results tr
- JOIN tour_courses tc ON tr.tour_course_id = tc.id
- JOIN courses c ON tc.course_id = c.id
- JOIN layouts l ON tc.layout_id = l.id
- JOIN tour_players tp ON tp.tour_id = tc.tour_id AND tp.pdga_number = tr.pdga_number
- WHERE tc.tour_id = ?
- ORDER BY tc.id ASC, tr.total_strokes ASC`,
- [tourId],
- (err, rows) => {
- if (err) reject(err);
- else resolve(rows);
- }
- );
- });
-}
-
-module.exports = {
- createTour,
- getTourByCode,
- addCourseToTour,
- getTourCourses,
- getTourCourseById,
- joinTour,
- getTourPlayers,
- getPlayerInTour,
- recordResult,
- getResultsForTour
-};
diff --git a/src/routes/courses.js b/src/routes/courses.js
index 0f532c8..fa0db78 100644
--- a/src/routes/courses.js
+++ b/src/routes/courses.js
@@ -1,8 +1,7 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
-const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating, saveCourseToDB, getOrCreateLayout } = require('../models/course');
-const { searchTjingCourses, getTjingCourse } = require('../scrapers/tjing');
+const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating } = require('../models/course');
const { launchBrowser } = require('../scrapers/browser');
const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer');
const logger = require('../logger');
@@ -370,55 +369,4 @@ router.post('/api/scrape-event-results/:courseId', async (req, res) => {
}
});
-// Search Tjing for courses
-router.get('/api/tjing/search', async (req, res) => {
- try {
- const { q } = req.query;
- if (!q || q.trim().length < 2) {
- return res.json([]);
- }
-
- const courses = await searchTjingCourses(q.trim());
- res.json(courses);
- } catch (error) {
- logger.error({ err: error }, 'Error searching Tjing');
- res.status(500).json({ error: 'Failed to search Tjing' });
- }
-});
-
-// Import a course from Tjing
-router.post('/api/tjing/import/:tjingId', async (req, res) => {
- try {
- const { tjingId } = req.params;
- const courseData = await getTjingCourse(tjingId);
-
- if (courseData.layouts.length === 0) {
- return res.status(400).json({ error: 'Course has no published layouts on Tjing' });
- }
-
- const courseId = await saveCourseToDB({
- name: courseData.name,
- link: 'https://tjing.se/courses/' + tjingId,
- city: courseData.address || ''
- });
-
- let layoutCount = 0;
- for (const layout of courseData.layouts) {
- await getOrCreateLayout(courseId, layout.name, layout.par);
- layoutCount++;
- }
-
- logger.info(`Imported from Tjing: "${courseData.name}" with ${layoutCount} layouts`);
- res.json({
- success: true,
- message: `Imported "${courseData.name}" with ${layoutCount} layouts`,
- courseName: courseData.name,
- layoutCount
- });
- } catch (error) {
- logger.error({ err: error }, 'Error importing from Tjing');
- res.status(500).json({ error: 'Failed to import course from Tjing' });
- }
-});
-
module.exports = router;
diff --git a/src/routes/pages.js b/src/routes/pages.js
index 106a13d..2eb677f 100644
--- a/src/routes/pages.js
+++ b/src/routes/pages.js
@@ -14,8 +14,4 @@ router.get('/courses.html', (req, res) => {
res.redirect('/courses');
});
-router.get('/tours', (req, res) => {
- res.render('tours');
-});
-
module.exports = router;
diff --git a/src/routes/tours.js b/src/routes/tours.js
deleted file mode 100644
index 827d65b..0000000
--- a/src/routes/tours.js
+++ /dev/null
@@ -1,174 +0,0 @@
-const express = require('express');
-const router = express.Router();
-const { getAllCoursesFromDB, getLayoutsForCourse, getOrCreateLayout } = require('../models/course');
-const {
- createTour, getTourByCode, addCourseToTour, getTourCourses,
- getTourCourseById, joinTour, getPlayerInTour, recordResult
-} = require('../models/tour');
-const { generateTourCode, isTourActive, isTourFinished, calculateLeaderboard } = require('../services/tour-service');
-const logger = require('../logger');
-
-// Create a new tour
-router.post('/api/tours', async (req, res) => {
- try {
- const { name, startDate, endDate, courses } = req.body;
-
- if (!name || !startDate || !endDate || !courses || courses.length === 0) {
- return res.status(400).json({ error: 'Name, dates, and at least one course are required' });
- }
-
- if (new Date(endDate) <= new Date(startDate)) {
- return res.status(400).json({ error: 'End date must be after start date' });
- }
-
- const code = generateTourCode();
- const tourId = await createTour(name, code, startDate, endDate);
-
- for (const course of courses) {
- let layoutId = course.layoutId;
-
- // Create new layout if name and par provided instead of layoutId
- if (!layoutId && course.layoutName && course.par) {
- layoutId = await getOrCreateLayout(course.courseId, course.layoutName, parseInt(course.par));
- }
-
- if (!layoutId) {
- return res.status(400).json({ error: 'Each course needs a layout (existing or new with name and par)' });
- }
-
- await addCourseToTour(tourId, course.courseId, layoutId);
- }
-
- logger.info(`Tour created: "${name}" (${code}) with ${courses.length} courses`);
- res.json({ success: true, code, message: `Tour "${name}" created` });
- } catch (error) {
- logger.error('Error creating tour:', error.message);
- res.status(500).json({ error: 'Failed to create tour' });
- }
-});
-
-// Join a tour
-router.post('/api/tours/:code/join', async (req, res) => {
- try {
- const { code } = req.params;
- const { pdgaNumber, playerName } = req.body;
-
- if (!pdgaNumber || !playerName) {
- return res.status(400).json({ error: 'PDGA number and name are required' });
- }
-
- const tour = await getTourByCode(code);
- if (!tour) {
- return res.status(404).json({ error: 'Tour not found' });
- }
-
- await joinTour(tour.id, pdgaNumber, playerName);
- logger.info(`Player ${playerName} (${pdgaNumber}) joined tour ${code}`);
- res.json({ success: true, message: `Joined tour "${tour.name}"` });
- } catch (error) {
- logger.error('Error joining tour:', error.message);
- res.status(500).json({ error: 'Failed to join tour' });
- }
-});
-
-// Record a result
-router.post('/api/tours/:code/results', async (req, res) => {
- try {
- const { code } = req.params;
- const { pdgaNumber, tourCourseId, totalStrokes } = req.body;
-
- if (!pdgaNumber || !tourCourseId || !totalStrokes) {
- return res.status(400).json({ error: 'PDGA number, course, and strokes are required' });
- }
-
- const tour = await getTourByCode(code);
- if (!tour) {
- return res.status(404).json({ error: 'Tour not found' });
- }
-
- // Verify tourCourseId belongs to this tour
- const tourCourse = await getTourCourseById(tourCourseId);
- if (!tourCourse || tourCourse.tour_id !== tour.id) {
- return res.status(400).json({ error: 'Invalid course for this tour' });
- }
-
- if (!isTourActive(tour)) {
- return res.status(400).json({ error: 'Tour is not active. Results can only be recorded during the tour period.' });
- }
-
- const player = await getPlayerInTour(tour.id, pdgaNumber);
- if (!player) {
- return res.status(403).json({ error: 'You must join the tour before recording results' });
- }
-
- await recordResult(tourCourseId, pdgaNumber, parseInt(totalStrokes));
- logger.info(`Result recorded: ${pdgaNumber} scored ${totalStrokes} on course ${tourCourseId} in tour ${code}`);
- res.json({ success: true, message: 'Result recorded' });
- } catch (error) {
- logger.error('Error recording result:', error.message);
- res.status(500).json({ error: 'Failed to record result' });
- }
-});
-
-// Get leaderboard partial (HTMX)
-router.get('/partials/tour-leaderboard/:code', async (req, res) => {
- try {
- const tour = await getTourByCode(req.params.code);
- if (!tour) {
- return res.status(404).send('Tour not found
');
- }
-
- const { courses, leaderboard } = await calculateLeaderboard(tour.id);
- res.render('../partials/tour-leaderboard', {
- tour,
- courses,
- leaderboard,
- isActive: isTourActive(tour),
- isFinished: isTourFinished(tour)
- });
- } catch (error) {
- logger.error('Error loading leaderboard:', error.message);
- res.status(500).send('Error loading leaderboard
');
- }
-});
-
-// Get all courses with their layouts (if any) for tour creation form
-router.get('/api/tours/courses-with-layouts', async (req, res) => {
- try {
- const courses = await getAllCoursesFromDB();
- const result = [];
-
- for (const course of courses) {
- const layouts = await getLayoutsForCourse(course.id);
- result.push({ ...course, layouts });
- }
-
- res.json(result);
- } catch (error) {
- logger.error('Error fetching courses with layouts:', error.message);
- res.status(500).json({ error: 'Failed to fetch courses' });
- }
-});
-
-// Tour page
-router.get('/tours/:code', async (req, res) => {
- try {
- const tour = await getTourByCode(req.params.code);
- if (!tour) {
- return res.status(404).render('tour', { tour: null });
- }
-
- const courses = await getTourCourses(tour.id);
- res.render('tour', {
- tour,
- courses,
- isActive: isTourActive(tour),
- isFinished: isTourFinished(tour)
- });
- } catch (error) {
- logger.error('Error loading tour page:', error.message);
- res.status(500).send('Error loading tour');
- }
-});
-
-module.exports = router;
diff --git a/src/scrapers/tjing.js b/src/scrapers/tjing.js
deleted file mode 100644
index a9d5bdc..0000000
--- a/src/scrapers/tjing.js
+++ /dev/null
@@ -1,95 +0,0 @@
-const logger = require('../logger');
-
-const TJING_API = 'https://api.tjing.se/graphql';
-
-async function searchTjingCourses(searchTerm) {
- const query = `{
- courses(first: 10, filter: { search: "${searchTerm.replace(/"/g, '\\"')}" }) {
- id
- name
- address
- type
- }
- }`;
-
- const response = await fetch(TJING_API, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query })
- });
-
- if (!response.ok) {
- throw new Error(`Tjing API returned ${response.status}`);
- }
-
- const data = await response.json();
-
- if (data.errors) {
- throw new Error(data.errors[0].message);
- }
-
- return data.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 response = await fetch(TJING_API, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ query })
- });
-
- if (!response.ok) {
- throw new Error(`Tjing API returned ${response.status}`);
- }
-
- const data = await response.json();
-
- if (data.errors) {
- throw new Error(data.errors[0].message);
- }
-
- const course = data.data.course;
- if (!course) {
- throw new 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 {
- name: course.name,
- address: course.address,
- tjingId: course.id,
- layouts
- };
-}
-
-module.exports = {
- searchTjingCourses,
- getTjingCourse
-};
diff --git a/src/services/tour-service.js b/src/services/tour-service.js
deleted file mode 100644
index 0624771..0000000
--- a/src/services/tour-service.js
+++ /dev/null
@@ -1,82 +0,0 @@
-const crypto = require('crypto');
-const { getTourCourses, getResultsForTour, getTourPlayers } = require('../models/tour');
-
-function generateTourCode() {
- return crypto.randomBytes(3).toString('hex').toUpperCase();
-}
-
-function isTourActive(tour) {
- const now = new Date().toISOString().split('T')[0];
- return now >= tour.start_date && now <= tour.end_date;
-}
-
-function isTourFinished(tour) {
- const now = new Date().toISOString().split('T')[0];
- return now > tour.end_date;
-}
-
-async function calculateLeaderboard(tourId) {
- const courses = await getTourCourses(tourId);
- const results = await getResultsForTour(tourId);
- const players = await getTourPlayers(tourId);
-
- // Group results by course
- const resultsByCourse = {};
- for (const course of courses) {
- resultsByCourse[course.tour_course_id] = results
- .filter(r => r.tour_course_id === course.tour_course_id)
- .sort((a, b) => a.total_strokes - b.total_strokes);
- }
-
- // Calculate points per course
- const playerPoints = {};
- for (const player of players) {
- playerPoints[player.pdga_number] = {
- player_name: player.player_name,
- pdga_number: player.pdga_number,
- courses: {},
- total_points: 0
- };
- }
-
- const pointsByRank = [10, 8, 6, 4, 3, 2, 1];
-
- for (const course of courses) {
- const courseResults = resultsByCourse[course.tour_course_id] || [];
-
- let rank = 1;
- for (let i = 0; i < courseResults.length; i++) {
- // Check for ties with previous player
- if (i > 0 && courseResults[i].total_strokes > courseResults[i - 1].total_strokes) {
- rank = i + 1;
- }
-
- const points = rank <= pointsByRank.length ? pointsByRank[rank - 1] : 0;
- const r = courseResults[i];
- const relativePar = r.total_strokes - course.par;
-
- if (playerPoints[r.pdga_number]) {
- playerPoints[r.pdga_number].courses[course.tour_course_id] = {
- total_strokes: r.total_strokes,
- relative_par: relativePar,
- points: points,
- rank: rank
- };
- playerPoints[r.pdga_number].total_points += points;
- }
- }
- }
-
- // Sort by total points descending
- const leaderboard = Object.values(playerPoints)
- .sort((a, b) => b.total_points - a.total_points);
-
- return { courses, leaderboard };
-}
-
-module.exports = {
- generateTourCode,
- isTourActive,
- isTourFinished,
- calculateLeaderboard
-};
diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs
index 8a33e53..0fe9cc3 100644
--- a/views/pages/courses.ejs
+++ b/views/pages/courses.ejs
@@ -19,23 +19,6 @@
-
-
Import from Tjing
-
-
-
-
-
-
-
Loading courses...
`; %>
diff --git a/views/pages/tour.ejs b/views/pages/tour.ejs
deleted file mode 100644
index 4344504..0000000
--- a/views/pages/tour.ejs
+++ /dev/null
@@ -1,91 +0,0 @@
-<% if (!tour) { %>
- <% var body = `
-
-
Tour Not Found
-
The tour code is invalid or the tour has been removed.
-
Back to Tours
-
- `; %>
- <%- include('../partials/layout', {
- title: 'Tour Not Found',
- heading: 'Tour Not Found',
- activePage: 'tours',
- cssFiles: ['tours.css'],
- body: body
- }) %>
-<% } else { %>
- <%
- var statusBadge = isActive
- ? 'Active'
- : isFinished
- ? 'Finished'
- : 'Upcoming';
-
- var courseOptionsHtml = '';
- courses.forEach(function(c) {
- courseOptionsHtml += '';
- });
-
- var escapedCode = tour.code.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
- var escapedStartDate = tour.start_date.replace(/&/g, '&').replace(//g, '>');
- var escapedEndDate = tour.end_date.replace(/&/g, '&').replace(//g, '>');
- %>
-
- <% var body = `
-
-
-
-
-
-
Record Result
-
-
-
-
-
-
-
-
-
Loading leaderboard...
-
- `; %>
-
- <%- include('../partials/layout', {
- title: tour.name + ' - PDGA Tours',
- heading: tour.name,
- activePage: 'tours',
- cssFiles: ['tours.css'],
- jsFiles: ['tour.js'],
- initScript: 'initTour("' + escapedCode + '");',
- body: body
- }) %>
-<% } %>
\ No newline at end of file
diff --git a/views/pages/tours.ejs b/views/pages/tours.ejs
deleted file mode 100644
index 6606458..0000000
--- a/views/pages/tours.ejs
+++ /dev/null
@@ -1,67 +0,0 @@
-<% var body = `
-
-
-
-
Join a Tour
-
-
-
-
-
-
-
-
Tour Created!
-
Share this link with players:
-
-
-`; %>
-
-<%- include('../partials/layout', {
- title: 'Tours - PDGA Ratings',
- heading: 'Tours',
- activePage: 'tours',
- cssFiles: ['tours.css'],
- jsFiles: ['tours.js'],
- body: body
-}) %>
\ No newline at end of file
diff --git a/views/partials/nav.ejs b/views/partials/nav.ejs
index aa1d446..c57b41f 100644
--- a/views/partials/nav.ejs
+++ b/views/partials/nav.ejs
@@ -1,5 +1,4 @@
\ No newline at end of file
diff --git a/views/partials/tour-leaderboard.ejs b/views/partials/tour-leaderboard.ejs
deleted file mode 100644
index fdb902b..0000000
--- a/views/partials/tour-leaderboard.ejs
+++ /dev/null
@@ -1,52 +0,0 @@
-<% if (leaderboard.length === 0) { %>
-
-
No players have joined yet. Share the tour link to get started!
-
-<% } else { %>
-
-
Leaderboard
-
-
-
- | # |
- Player |
- <% courses.forEach(function(c) { %>
- <%= c.course_name %> <%= c.layout_name %> |
- <% }); %>
- Points |
-
-
-
- <% var rank = 1; %>
- <% leaderboard.forEach(function(player, i) { %>
- <% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %>
-
- | <%= rank %> |
-
- <%= player.player_name %>
- <%= player.pdga_number %>
- |
- <% courses.forEach(function(c) { %>
-
- <% var result = player.courses[c.tour_course_id]; %>
- <% if (result) { %>
- <%= result.total_strokes %>
- <% if (result.relative_par > 0) { %>
- (+<%= result.relative_par %>)
- <% } else if (result.relative_par < 0) { %>
- (<%= result.relative_par %>)
- <% } else { %>
- (E)
- <% } %>
- <% } else { %>
- -
- <% } %>
- |
- <% }); %>
- <%= player.total_points %> |
-
- <% }); %>
-
-
-
-<% } %>
\ No newline at end of file