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 = ` -
-

Create a Tour

-
-
- - -
-
-
- - -
-
- - -
-
-
- -
-
- - -
-
- -
- -
-
- -
-

Join a Tour

-
- - -
-
- - -`; %> - -<%- 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 = ` -
-
-
- ${statusBadge} - ${tour.start_date} — ${tour.end_date} - ${tour.code} -
-
-
- -
-

Join This Tour

-
- - - -
-
- - - -
-
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

- - - - - - <% courses.forEach(function(c) { %> - - <% }); %> - - - - - <% var rank = 1; %> - <% leaderboard.forEach(function(player, i) { %> - <% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %> - - - - <% courses.forEach(function(c) { %> - - <% }); %> - - - <% }); %> - -
#Player<%= c.course_name %>
<%= c.layout_name %>
Points
<%= rank %> - <%= player.player_name %> - <%= player.pdga_number %> - - <% 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

-
- - -
-
-
-
`; %> 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 = ` -
-
-
- ${statusBadge} - ${escapedStartDate} — ${escapedEndDate} - ${escapedCode} -
-
-
- -
-

Join This Tour

-
- - - -
-
- - - -
-
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 = ` -
-

Create a Tour

-
-
- - -
-
-
- - -
-
- - -
-
-
- -
-
- - -
-
- -
- -
-
- -
-

Join a Tour

-
- - -
-
- - -`; %> - -<%- 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

- - - - - - <% courses.forEach(function(c) { %> - - <% }); %> - - - - - <% var rank = 1; %> - <% leaderboard.forEach(function(player, i) { %> - <% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %> - - - - <% courses.forEach(function(c) { %> - - <% }); %> - - - <% }); %> - -
#Player<%= c.course_name %>
<%= c.layout_name %>
Points
<%= rank %> - <%= player.player_name %> - <%= player.pdga_number %> - - <% 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