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 = `
+
+
+
+
Join a Tour
+
+
+
+
+
+
+
+
Tour Created!
+
Share this link with players:
+
+
+`; %>
+
+<%- include('../partials/layout', {
+ title: 'Tours - PDGA Ratings',
+ heading: 'Tours',
+ activePage: 'tours',
+ cssFiles: ['tours.css'],
+ jsFiles: ['tours.js'],
+ body: body
+}) %>
+```
+
+- [ ] **Step 2: Create tours.js**
+
+Use safe DOM methods (createElement, textContent, appendChild) instead of innerHTML for user-controlled content.
+
+```javascript
+let coursesData = [];
+
+async function loadCourses() {
+ try {
+ const res = await fetch('/api/tours/courses-with-layouts');
+ coursesData = await res.json();
+ updateCourseDropdowns();
+ } catch (err) {
+ console.error('Failed to load courses:', err);
+ }
+}
+
+function populateSelectWithCourses(select) {
+ select.textContent = '';
+ var defaultOpt = document.createElement('option');
+ defaultOpt.value = '';
+ defaultOpt.textContent = 'Select a course...';
+ select.appendChild(defaultOpt);
+
+ coursesData.forEach(function(course) {
+ var opt = document.createElement('option');
+ opt.value = course.id;
+ opt.textContent = course.name + ' (' + course.city + ')';
+ select.appendChild(opt);
+ });
+}
+
+function updateCourseDropdowns() {
+ document.querySelectorAll('.course-select').forEach(function(select) {
+ var currentValue = select.value;
+ populateSelectWithCourses(select);
+ select.value = currentValue;
+ });
+}
+
+function onCourseChange(courseSelect) {
+ var index = courseSelect.dataset.index;
+ var layoutSelect = document.querySelector('.layout-select[data-index="' + index + '"]');
+ var courseId = parseInt(courseSelect.value);
+
+ layoutSelect.textContent = '';
+ layoutSelect.disabled = true;
+
+ var defaultOpt = document.createElement('option');
+ defaultOpt.value = '';
+ defaultOpt.textContent = 'Select layout...';
+ layoutSelect.appendChild(defaultOpt);
+
+ if (!courseId) return;
+
+ var course = coursesData.find(function(c) { return c.id === courseId; });
+ if (!course) return;
+
+ course.layouts.forEach(function(layout) {
+ var opt = document.createElement('option');
+ opt.value = layout.id;
+ opt.textContent = layout.name + ' (Par ' + layout.par + ')';
+ layoutSelect.appendChild(opt);
+ });
+ layoutSelect.disabled = false;
+}
+
+var courseIndex = 1;
+
+function addCourseEntry() {
+ var container = document.getElementById('course-selector');
+ var entry = document.createElement('div');
+ entry.className = 'course-entry';
+
+ var courseSelect = document.createElement('select');
+ courseSelect.className = 'input course-select';
+ courseSelect.dataset.index = courseIndex;
+ populateSelectWithCourses(courseSelect);
+ courseSelect.addEventListener('change', function() { onCourseChange(courseSelect); });
+
+ var layoutSelect = document.createElement('select');
+ layoutSelect.className = 'input layout-select';
+ layoutSelect.dataset.index = courseIndex;
+ layoutSelect.disabled = true;
+ var defaultOpt = document.createElement('option');
+ defaultOpt.value = '';
+ defaultOpt.textContent = 'Select course first';
+ layoutSelect.appendChild(defaultOpt);
+
+ var removeBtn = document.createElement('button');
+ removeBtn.className = 'btn-remove';
+ removeBtn.type = 'button';
+ var icon = document.createElement('i');
+ icon.className = 'fas fa-times';
+ removeBtn.appendChild(icon);
+ removeBtn.addEventListener('click', function() { entry.remove(); });
+
+ entry.appendChild(courseSelect);
+ entry.appendChild(layoutSelect);
+ entry.appendChild(removeBtn);
+ container.appendChild(entry);
+
+ courseIndex++;
+}
+
+async function createTour() {
+ var name = document.getElementById('tour-name').value.trim();
+ var startDate = document.getElementById('tour-start').value;
+ var endDate = document.getElementById('tour-end').value;
+
+ if (!name || !startDate || !endDate) {
+ alert('Please fill in name and dates');
+ return;
+ }
+
+ var courses = [];
+ document.querySelectorAll('.course-entry').forEach(function(entry) {
+ var courseId = entry.querySelector('.course-select').value;
+ var layoutId = entry.querySelector('.layout-select').value;
+ if (courseId && layoutId) {
+ courses.push({ courseId: parseInt(courseId), layoutId: parseInt(layoutId) });
+ }
+ });
+
+ if (courses.length === 0) {
+ alert('Please select at least one course with a layout');
+ return;
+ }
+
+ var btn = document.getElementById('create-tour-btn');
+ btn.disabled = true;
+
+ try {
+ var res = await fetch('/api/tours', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: name, startDate: startDate, endDate: endDate, courses: courses })
+ });
+
+ var data = await res.json();
+ if (data.success) {
+ var link = document.getElementById('tour-link');
+ link.href = '/tours/' + data.code;
+ link.textContent = window.location.origin + '/tours/' + data.code;
+ document.getElementById('tour-created').style.display = '';
+ } else {
+ alert(data.error || 'Failed to create tour');
+ }
+ } catch (err) {
+ console.error('Error creating tour:', err);
+ alert('Failed to create tour');
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+function goToTour() {
+ var code = document.getElementById('tour-code').value.trim().toUpperCase();
+ if (code) {
+ window.location.href = '/tours/' + code;
+ }
+}
+
+// Initialize
+document.addEventListener('DOMContentLoaded', function() {
+ loadCourses();
+
+ // Delegate change events for course selects
+ document.getElementById('course-selector').addEventListener('change', function(e) {
+ if (e.target.classList.contains('course-select')) {
+ onCourseChange(e.target);
+ }
+ });
+});
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add views/pages/tours.ejs public/js/tours.js
+git commit -m "feat: add create tour page with course/layout selection"
+```
+
+---
+
+### Task 6: Tour View Page
+
+**Files:**
+- Create: `views/pages/tour.ejs`
+- Create: `public/js/tour.js`
+
+- [ ] **Step 1: Create tour.ejs**
+
+```ejs
+<% if (!tour) { %>
+ <% var body = `
+
+
Tour Not Found
+
The tour code is invalid or the tour has been removed.
+
Back to Tours
+
+ `; %>
+ <%- include('../partials/layout', {
+ title: 'Tour Not Found',
+ heading: 'Tour Not Found',
+ activePage: 'tours',
+ cssFiles: ['tours.css'],
+ body: body
+ }) %>
+<% } else { %>
+ <% var statusBadge = isActive
+ ? 'Active'
+ : isFinished
+ ? 'Finished'
+ : 'Upcoming'; %>
+
+ <% var courseOptions = courses.map(function(c) {
+ return '';
+ }).join(''); %>
+
+ <% var body = `
+
+
+
+
+
+
Record Result
+
+
+
+
+
+
+
+
+
Loading leaderboard...
+
+ `; %>
+
+ <%- include('../partials/layout', {
+ title: tour.name + ' - PDGA Tours',
+ heading: tour.name,
+ activePage: 'tours',
+ cssFiles: ['tours.css'],
+ jsFiles: ['tour.js'],
+ initScript: 'initTour("' + tour.code + '");',
+ body: body
+ }) %>
+<% } %>
+```
+
+- [ ] **Step 2: Create tour.js**
+
+```javascript
+var currentPdgaNumber = null;
+
+function initTour(code) {
+ // Check if player already joined (stored in sessionStorage)
+ var stored = sessionStorage.getItem('tour_' + code);
+ if (stored) {
+ var data = JSON.parse(stored);
+ currentPdgaNumber = data.pdgaNumber;
+ showResultSection();
+ }
+}
+
+function showResultSection() {
+ var joinSection = document.getElementById('join-section');
+ var resultSection = document.getElementById('result-section');
+ if (joinSection) joinSection.style.display = 'none';
+ if (resultSection) resultSection.style.display = '';
+}
+
+async function joinTour() {
+ var pdgaNumber = document.getElementById('pdga-number').value.trim();
+ var playerName = document.getElementById('player-name').value.trim();
+
+ if (!pdgaNumber || !playerName) {
+ alert('Please enter your PDGA number and name');
+ return;
+ }
+
+ var code = window.location.pathname.split('/').pop();
+
+ try {
+ var res = await fetch('/api/tours/' + code + '/join', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName })
+ });
+
+ var data = await res.json();
+ if (data.success) {
+ currentPdgaNumber = pdgaNumber;
+ sessionStorage.setItem('tour_' + code, JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName }));
+ showResultSection();
+ // Refresh leaderboard
+ htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
+ } else {
+ alert(data.error || 'Failed to join tour');
+ }
+ } catch (err) {
+ console.error('Error joining tour:', err);
+ alert('Failed to join tour');
+ }
+}
+
+async function recordResult(code) {
+ var tourCourseId = document.getElementById('result-course').value;
+ var totalStrokes = document.getElementById('result-strokes').value;
+
+ if (!tourCourseId || !totalStrokes) {
+ alert('Please select a course and enter your strokes');
+ return;
+ }
+
+ if (!currentPdgaNumber) {
+ alert('Please join the tour first');
+ return;
+ }
+
+ try {
+ var res = await fetch('/api/tours/' + code + '/results', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ pdgaNumber: currentPdgaNumber,
+ tourCourseId: parseInt(tourCourseId),
+ totalStrokes: parseInt(totalStrokes)
+ })
+ });
+
+ var data = await res.json();
+ if (data.success) {
+ document.getElementById('result-strokes').value = '';
+ // Refresh leaderboard
+ htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
+ } else {
+ alert(data.error || 'Failed to record result');
+ }
+ } catch (err) {
+ console.error('Error recording result:', err);
+ alert('Failed to record result');
+ }
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add views/pages/tour.ejs public/js/tour.js
+git commit -m "feat: add tour view page with join form and result recording"
+```
+
+---
+
+### Task 7: Leaderboard Partial
+
+**Files:**
+- Create: `views/partials/tour-leaderboard.ejs`
+
+- [ ] **Step 1: Create tour-leaderboard.ejs**
+
+```ejs
+<% if (leaderboard.length === 0) { %>
+
+
No players have joined yet. Share the tour link to get started!
+
+<% } else { %>
+
+
Leaderboard
+
+
+
+ | # |
+ Player |
+ <% courses.forEach(function(c) { %>
+ <%= c.course_name %> <%= c.layout_name %> |
+ <% }); %>
+ Points |
+
+
+
+ <% var rank = 1; %>
+ <% leaderboard.forEach(function(player, i) { %>
+ <% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %>
+
+ | <%= rank %> |
+
+ <%= player.player_name %>
+ <%= player.pdga_number %>
+ |
+ <% courses.forEach(function(c) { %>
+
+ <% var result = player.courses[c.tour_course_id]; %>
+ <% if (result) { %>
+ <%= result.total_strokes %>
+ <% if (result.relative_par > 0) { %>
+ (+<%= result.relative_par %>)
+ <% } else if (result.relative_par < 0) { %>
+ (<%= result.relative_par %>)
+ <% } else { %>
+ (E)
+ <% } %>
+ <% } else { %>
+ -
+ <% } %>
+ |
+ <% }); %>
+ <%= player.total_points %> |
+
+ <% }); %>
+
+
+
+<% } %>
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add views/partials/tour-leaderboard.ejs
+git commit -m "feat: add tour leaderboard HTMX partial with par display"
+```
+
+---
+
+### Task 8: Styling
+
+**Files:**
+- Create: `public/css/tours.css`
+
+- [ ] **Step 1: Create tours.css**
+
+Use the existing design system variables from `shared.css`. Style the tour-specific elements.
+
+```css
+/* Tour Form */
+
+.tour-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.form-group label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.form-row {
+ display: flex;
+ gap: 12px;
+}
+
+.form-row .form-group {
+ flex: 1;
+}
+
+/* Course Selector */
+
+.course-entry {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.course-entry select {
+ flex: 1;
+}
+
+.btn-secondary {
+ background: var(--surface-2);
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+ background: var(--surface-3);
+ color: var(--text-primary);
+}
+
+.btn-remove {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 6px;
+ border-radius: var(--radius-sm);
+ font-size: 14px;
+ transition: color var(--transition), background var(--transition);
+}
+
+.btn-remove:hover {
+ color: var(--red);
+ background: var(--red-subtle);
+}
+
+/* Tour Header */
+
+.tour-header {
+ margin-bottom: 24px;
+}
+
+.tour-meta {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.tour-dates,
+.tour-code-label {
+ font-size: 14px;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+/* Badges */
+
+.badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.badge-active {
+ background: var(--green-subtle);
+ color: var(--green);
+}
+
+.badge-finished {
+ background: var(--surface-3);
+ color: var(--text-secondary);
+}
+
+.badge-upcoming {
+ background: var(--accent-subtle);
+ color: var(--accent);
+}
+
+/* Tour Code Display */
+
+.tour-created-message {
+ text-align: center;
+ color: var(--text-secondary);
+ margin: 0 0 12px 0;
+}
+
+.tour-code-display {
+ text-align: center;
+ padding: 16px;
+ background: var(--surface-2);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+}
+
+.tour-code-display a {
+ font-family: var(--font-mono);
+ font-size: 16px;
+ font-weight: 500;
+}
+
+/* Leaderboard */
+
+.strokes {
+ font-weight: 600;
+ font-family: var(--font-mono);
+}
+
+.under-par {
+ color: var(--green);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.over-par {
+ color: var(--red);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.even-par {
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+/* Responsive */
+
+@media (max-width: 768px) {
+ .form-row {
+ flex-direction: column;
+ }
+
+ .course-entry {
+ flex-direction: column;
+ }
+
+ .course-entry select {
+ width: 100%;
+ }
+
+ .tour-meta {
+ gap: 8px;
+ }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add public/css/tours.css
+git commit -m "feat: add tour styling using existing design system"
+```
+
+---
+
+### Task 9: Integration Verification
+
+- [ ] **Step 1: Start the server**
+
+Run: `npm run dev`
+Expected: Server starts, database initializes with new tables, no errors.
+
+- [ ] **Step 2: Verify navigation**
+
+Navigate to `http://localhost:3000` — "Tours" link should appear in nav.
+
+- [ ] **Step 3: Verify tour creation flow**
+
+1. Go to `/tours`
+2. Fill in tour name, dates, select a course + layout
+3. Click "Create Tour"
+4. Verify tour code and link appear
+
+- [ ] **Step 4: Verify join + results flow**
+
+1. Navigate to the tour URL
+2. Enter PDGA number and name, click Join
+3. Select a course, enter strokes, click Save
+4. Verify leaderboard updates
+
+- [ ] **Step 5: Final commit if any fixes needed**
+
+```bash
+git add -A
+git commit -m "fix: integration fixes for async tours"
+```
diff --git a/public/css/tours.css b/public/css/tours.css
new file mode 100644
index 0000000..5b819b5
--- /dev/null
+++ b/public/css/tours.css
@@ -0,0 +1,211 @@
+/* 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/tour.js b/public/js/tour.js
new file mode 100644
index 0000000..83673a3
--- /dev/null
+++ b/public/js/tour.js
@@ -0,0 +1,91 @@
+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
new file mode 100644
index 0000000..93f209d
--- /dev/null
+++ b/public/js/tours.js
@@ -0,0 +1,255 @@
+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/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/course.js b/src/models/course.js
index e035e59..97ca377 100644
--- a/src/models/course.js
+++ b/src/models/course.js
@@ -69,10 +69,33 @@ function updateLayoutRating(courseId, layoutName, par, meanRating, ratingCount,
});
}
+function getOrCreateLayout(courseId, name, par) {
+ return new Promise((resolve, reject) => {
+ db.get(
+ 'SELECT id FROM layouts WHERE course_id = ? AND name = ? AND par = ?',
+ [courseId, name, par],
+ (err, row) => {
+ if (err) return reject(err);
+ if (row) return resolve(row.id);
+
+ db.run(
+ 'INSERT INTO layouts (course_id, name, par) VALUES (?, ?, ?)',
+ [courseId, name, par],
+ function(err) {
+ if (err) reject(err);
+ else resolve(this.lastID);
+ }
+ );
+ }
+ );
+ });
+}
+
module.exports = {
saveCourseToDB,
getAllCoursesFromDB,
saveLayoutToDB,
getLayoutsForCourse,
+ getOrCreateLayout,
updateLayoutRating
};
diff --git a/src/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..827d65b
--- /dev/null
+++ b/src/routes/tours.js
@@ -0,0 +1,174 @@
+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/services/tour-service.js b/src/services/tour-service.js
new file mode 100644
index 0000000..0624771
--- /dev/null
+++ b/src/services/tour-service.js
@@ -0,0 +1,82 @@
+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/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 = `
+
+
+
+
+
+
Record Result
+
+
+
+
+
+
+
+
+
Loading leaderboard...
+
+ `; %>
+
+ <%- include('../partials/layout', {
+ title: tour.name + ' - PDGA Tours',
+ heading: tour.name,
+ activePage: 'tours',
+ cssFiles: ['tours.css'],
+ jsFiles: ['tour.js'],
+ initScript: 'initTour("' + escapedCode + '");',
+ body: body
+ }) %>
+<% } %>
\ No newline at end of file
diff --git a/views/pages/tours.ejs b/views/pages/tours.ejs
new file mode 100644
index 0000000..6606458
--- /dev/null
+++ b/views/pages/tours.ejs
@@ -0,0 +1,67 @@
+<% var body = `
+
+
+
+
Join a Tour
+
+
+
+
+
+
+
+
Tour Created!
+
Share this link with players:
+
+
+`; %>
+
+<%- include('../partials/layout', {
+ title: 'Tours - PDGA Ratings',
+ heading: 'Tours',
+ activePage: 'tours',
+ cssFiles: ['tours.css'],
+ jsFiles: ['tours.js'],
+ body: body
+}) %>
\ No newline at end of file
diff --git a/views/partials/nav.ejs b/views/partials/nav.ejs
index 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
+
+
+
+ | # |
+ Player |
+ <% courses.forEach(function(c) { %>
+ <%= c.course_name %> <%= c.layout_name %> |
+ <% }); %>
+ Points |
+
+
+
+ <% var rank = 1; %>
+ <% leaderboard.forEach(function(player, i) { %>
+ <% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %>
+
+ | <%= rank %> |
+
+ <%= player.player_name %>
+ <%= player.pdga_number %>
+ |
+ <% courses.forEach(function(c) { %>
+
+ <% var result = player.courses[c.tour_course_id]; %>
+ <% if (result) { %>
+ <%= result.total_strokes %>
+ <% if (result.relative_par > 0) { %>
+ (+<%= result.relative_par %>)
+ <% } else if (result.relative_par < 0) { %>
+ (<%= result.relative_par %>)
+ <% } else { %>
+ (E)
+ <% } %>
+ <% } else { %>
+ -
+ <% } %>
+ |
+ <% }); %>
+ <%= player.total_points %> |
+
+ <% }); %>
+
+
+
+<% } %>
\ No newline at end of file