diff --git a/docs/superpowers/plans/2026-03-19-async-tours.md b/docs/superpowers/plans/2026-03-19-async-tours.md new file mode 100644 index 0000000..bc1b930 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-async-tours.md @@ -0,0 +1,1366 @@ +# 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/tours.css b/public/css/tours.css new file mode 100644 index 0000000..171eb7c --- /dev/null +++ b/public/css/tours.css @@ -0,0 +1,188 @@ +/* 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; + } +} \ No newline at end of file diff --git a/public/js/tour.js b/public/js/tour.js new file mode 100644 index 0000000..d827525 --- /dev/null +++ b/public/js/tour.js @@ -0,0 +1,91 @@ +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'); + } +} \ No newline at end of file diff --git a/public/js/tours.js b/public/js/tours.js new file mode 100644 index 0000000..9f8d384 --- /dev/null +++ b/public/js/tours.js @@ -0,0 +1,169 @@ +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 = 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); + } + }); +}); \ No newline at end of file diff --git a/server.js b/server.js index 7747255..b246885 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ 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'); @@ -17,6 +18,7 @@ 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 ed781fa..fc2453f 100644 --- a/src/db.js +++ b/src/db.js @@ -92,6 +92,54 @@ 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/tour.js b/src/models/tour.js new file mode 100644 index 0000000..bf2bb60 --- /dev/null +++ b/src/models/tour.js @@ -0,0 +1,164 @@ +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/pages.js b/src/routes/pages.js index 2eb677f..106a13d 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -14,4 +14,8 @@ 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 new file mode 100644 index 0000000..606a85d --- /dev/null +++ b/src/routes/tours.js @@ -0,0 +1,165 @@ +const express = require('express'); +const router = express.Router(); +const { getAllCoursesFromDB, getLayoutsForCourse } = 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) { + 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' }); + } + + // 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 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; diff --git a/src/services/tour-service.js b/src/services/tour-service.js new file mode 100644 index 0000000..36dadc7 --- /dev/null +++ b/src/services/tour-service.js @@ -0,0 +1,81 @@ +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 +}; diff --git a/views/pages/tour.ejs b/views/pages/tour.ejs new file mode 100644 index 0000000..4344504 --- /dev/null +++ b/views/pages/tour.ejs @@ -0,0 +1,91 @@ +<% 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 new file mode 100644 index 0000000..6606458 --- /dev/null +++ b/views/pages/tours.ejs @@ -0,0 +1,67 @@ +<% 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 c57b41f..aa1d446 100644 --- a/views/partials/nav.ejs +++ b/views/partials/nav.ejs @@ -1,4 +1,5 @@ \ No newline at end of file diff --git a/views/partials/tour-leaderboard.ejs b/views/partials/tour-leaderboard.ejs new file mode 100644 index 0000000..fdb902b --- /dev/null +++ b/views/partials/tour-leaderboard.ejs @@ -0,0 +1,52 @@ +<% 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