Files
pdga-rating/docs/superpowers/plans/2026-03-19-async-tours.md
T
Samuel Enocsson 2ccb018bdf feat: add async tour system
Players can create tours with selected courses/layouts and a date range.
Others join via a 6-character tour code, play the courses, and report
their total strokes. Live leaderboard with points and +/- par display.

Includes: database schema, model, service, routes, views, and styling.
2026-03-20 07:39:43 +01:00

37 KiB

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.

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

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

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

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('<p>Tour not found</p>');
    }

    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('<p>Error loading leaderboard</p>');
  }
});

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

router.get('/tours', (req, res) => {
  res.render('tours');
});
  • Step 3: Mount tour routes in server.js

Add after the existing route imports:

const tourRoutes = require('./src/routes/tours');

Add after the existing app.use() calls:

app.use(tourRoutes);
  • Step 4: Add Tours to nav

In views/partials/nav.ejs, add after the Courses link:

<a href="/tours" class="<%= activePage === 'tours' ? 'active' : '' %>">Tours</a>
  • Step 5: Commit
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.

<% var body = `
    <div class="card-section">
        <h3>Create a Tour</h3>
        <div class="tour-form">
            <div class="form-group">
                <label for="tour-name">Tour Name</label>
                <input type="text" class="input" id="tour-name" placeholder="e.g. Summer Tour 2026" />
            </div>
            <div class="form-row">
                <div class="form-group">
                    <label for="tour-start">Start Date</label>
                    <input type="date" class="input" id="tour-start" />
                </div>
                <div class="form-group">
                    <label for="tour-end">End Date</label>
                    <input type="date" class="input" id="tour-end" />
                </div>
            </div>
            <div class="form-group">
                <label>Courses</label>
                <div id="course-selector">
                    <div class="course-entry">
                        <select class="input course-select" data-index="0">
                            <option value="">Loading courses...</option>
                        </select>
                        <select class="input layout-select" data-index="0" disabled>
                            <option value="">Select course first</option>
                        </select>
                    </div>
                </div>
                <button class="btn btn-secondary" onclick="addCourseEntry()" id="add-course-btn">
                    <i class="fas fa-plus"></i> Add Course
                </button>
            </div>
            <button class="btn" onclick="createTour()" id="create-tour-btn">
                <i class="fas fa-flag-checkered"></i> Create Tour
            </button>
        </div>
    </div>

    <div class="card-section">
        <h3>Join a Tour</h3>
        <div class="card-section-form">
            <input type="text" class="input" id="tour-code" placeholder="Tour code (e.g. X7K9M2)" maxlength="6" style="width: 180px;" />
            <button class="btn" onclick="goToTour()">
                <i class="fas fa-arrow-right"></i> Go
            </button>
        </div>
    </div>

    <div id="tour-created" class="card-section" style="display: none;">
        <h3>Tour Created!</h3>
        <p class="tour-created-message">Share this link with players:</p>
        <div class="tour-code-display">
            <a id="tour-link" href="#" target="_blank"></a>
        </div>
    </div>
`; %>

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

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

<% if (!tour) { %>
  <% var body = `
    <div class="card-section">
      <h3>Tour Not Found</h3>
      <p>The tour code is invalid or the tour has been removed.</p>
      <a href="/tours" class="btn"><i class="fas fa-arrow-left"></i> Back to Tours</a>
    </div>
  `; %>
  <%- include('../partials/layout', {
      title: 'Tour Not Found',
      heading: 'Tour Not Found',
      activePage: 'tours',
      cssFiles: ['tours.css'],
      body: body
  }) %>
<% } else { %>
  <% var statusBadge = isActive
    ? '<span class="badge badge-active">Active</span>'
    : isFinished
      ? '<span class="badge badge-finished">Finished</span>'
      : '<span class="badge badge-upcoming">Upcoming</span>'; %>

  <% var courseOptions = courses.map(function(c) {
    return '<option value="' + c.tour_course_id + '">' + c.course_name + ' - ' + c.layout_name + ' (Par ' + c.par + ')</option>';
  }).join(''); %>

  <% var body = `
    <div class="tour-header">
      <div class="tour-info">
        <div class="tour-meta">
          ${statusBadge}
          <span class="tour-dates"><i class="fas fa-calendar"></i> ${tour.start_date} &mdash; ${tour.end_date}</span>
          <span class="tour-code-label"><i class="fas fa-key"></i> ${tour.code}</span>
        </div>
      </div>
    </div>

    <div class="card-section" id="join-section">
      <h3>Join This Tour</h3>
      <div class="card-section-form">
        <input type="text" class="input" id="pdga-number" placeholder="PDGA Number" style="width: 160px;" />
        <input type="text" class="input" id="player-name" placeholder="Your Name" style="width: 200px;" />
        <button class="btn" onclick="joinTour()">
          <i class="fas fa-user-plus"></i> Join
        </button>
      </div>
    </div>

    <div class="card-section" id="result-section" style="display: none;">
      <h3>Record Result</h3>
      <div class="card-section-form">
        <select class="input" id="result-course" style="width: 280px;">
          <option value="">Select course...</option>
          ${courseOptions}
        </select>
        <input type="number" class="input" id="result-strokes" placeholder="Total strokes" style="width: 140px;" min="1" />
        <button class="btn" onclick="recordResult('${tour.code}')">
          <i class="fas fa-save"></i> Save
        </button>
      </div>
    </div>

    <div id="leaderboard-container"
         hx-get="/partials/tour-leaderboard/${tour.code}"
         hx-trigger="load">
      <div class="loading">Loading leaderboard...</div>
    </div>
  `; %>

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

<% if (leaderboard.length === 0) { %>
  <div class="card-section">
    <p style="text-align: center; color: var(--text-secondary);">No players have joined yet. Share the tour link to get started!</p>
  </div>
<% } else { %>
  <div class="card-section">
    <h3>Leaderboard</h3>
    <table>
      <thead>
        <tr>
          <th>#</th>
          <th>Player</th>
          <% courses.forEach(function(c) { %>
            <th class="mobile-hide"><%= c.course_name %><br><small><%= c.layout_name %></small></th>
          <% }); %>
          <th>Points</th>
        </tr>
      </thead>
      <tbody>
        <% var rank = 1; %>
        <% leaderboard.forEach(function(player, i) { %>
          <% if (i > 0 && player.total_points < leaderboard[i-1].total_points) rank = i + 1; %>
          <tr>
            <td><strong><%= rank %></strong></td>
            <td>
              <%= player.player_name %>
              <span style="color: var(--text-muted); font-size: 12px;"><%= player.pdga_number %></span>
            </td>
            <% courses.forEach(function(c) { %>
              <td class="mobile-hide">
                <% var result = player.courses[c.tour_course_id]; %>
                <% if (result) { %>
                  <span class="strokes"><%= result.total_strokes %></span>
                  <% if (result.relative_par > 0) { %>
                    <span class="over-par">(+<%= result.relative_par %>)</span>
                  <% } else if (result.relative_par < 0) { %>
                    <span class="under-par">(<%= result.relative_par %>)</span>
                  <% } else { %>
                    <span class="even-par">(E)</span>
                  <% } %>
                <% } else { %>
                  <span style="color: var(--text-muted);">-</span>
                <% } %>
              </td>
            <% }); %>
            <td><strong><%= player.total_points %></strong></td>
          </tr>
        <% }); %>
      </tbody>
    </table>
  </div>
<% } %>
  • Step 2: Commit
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.

/* 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
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
git add -A
git commit -m "fix: integration fixes for async tours"