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.
This commit is contained in:
Samuel Enocsson
2026-03-19 22:47:54 +01:00
parent d567c4bca9
commit 2ccb018bdf
14 changed files with 2489 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
/* Tour Form */
.tour-form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
margin: 0 auto;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-row {
display: flex;
gap: 12px;
}
.form-row .form-group {
flex: 1;
}
/* Course Selector */
.course-entry {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.course-entry select {
flex: 1;
}
.btn-secondary {
background: var(--surface-2);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--surface-3);
color: var(--text-primary);
}
.btn-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 6px;
border-radius: var(--radius-sm);
font-size: 14px;
transition: color var(--transition), background var(--transition);
}
.btn-remove:hover {
color: var(--red);
background: var(--red-subtle);
}
/* Tour Header */
.tour-header {
margin-bottom: 24px;
}
.tour-meta {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.tour-dates,
.tour-code-label {
font-size: 14px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
/* Badges */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-active {
background: var(--green-subtle);
color: var(--green);
}
.badge-finished {
background: var(--surface-3);
color: var(--text-secondary);
}
.badge-upcoming {
background: var(--accent-subtle);
color: var(--accent);
}
/* Tour Code Display */
.tour-created-message {
text-align: center;
color: var(--text-secondary);
margin: 0 0 12px 0;
}
.tour-code-display {
text-align: center;
padding: 16px;
background: var(--surface-2);
border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.tour-code-display a {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 500;
}
/* Leaderboard */
.strokes {
font-weight: 600;
font-family: var(--font-mono);
}
.under-par {
color: var(--green);
font-size: 12px;
font-weight: 600;
}
.over-par {
color: var(--red);
font-size: 12px;
font-weight: 600;
}
.even-par {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
/* Responsive */
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.course-entry {
flex-direction: column;
}
.course-entry select {
width: 100%;
}
.tour-meta {
gap: 8px;
}
}
+91
View File
@@ -0,0 +1,91 @@
var currentPdgaNumber = null;
function initTour(code) {
// Check if player already joined (stored in sessionStorage)
var stored = sessionStorage.getItem('tour_' + code);
if (stored) {
var data = JSON.parse(stored);
currentPdgaNumber = data.pdgaNumber;
showResultSection();
}
}
function showResultSection() {
var joinSection = document.getElementById('join-section');
var resultSection = document.getElementById('result-section');
if (joinSection) joinSection.style.display = 'none';
if (resultSection) resultSection.style.display = '';
}
async function joinTour() {
var pdgaNumber = document.getElementById('pdga-number').value.trim();
var playerName = document.getElementById('player-name').value.trim();
if (!pdgaNumber || !playerName) {
alert('Please enter your PDGA number and name');
return;
}
var code = window.location.pathname.split('/').pop();
try {
var res = await fetch('/api/tours/' + code + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName })
});
var data = await res.json();
if (data.success) {
currentPdgaNumber = pdgaNumber;
sessionStorage.setItem('tour_' + code, JSON.stringify({ pdgaNumber: pdgaNumber, playerName: playerName }));
showResultSection();
// Refresh leaderboard
htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
} else {
alert(data.error || 'Failed to join tour');
}
} catch (err) {
console.error('Error joining tour:', err);
alert('Failed to join tour');
}
}
async function recordResult(code) {
var tourCourseId = document.getElementById('result-course').value;
var totalStrokes = document.getElementById('result-strokes').value;
if (!tourCourseId || !totalStrokes) {
alert('Please select a course and enter your strokes');
return;
}
if (!currentPdgaNumber) {
alert('Please join the tour first');
return;
}
try {
var res = await fetch('/api/tours/' + code + '/results', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pdgaNumber: currentPdgaNumber,
tourCourseId: parseInt(tourCourseId),
totalStrokes: parseInt(totalStrokes)
})
});
var data = await res.json();
if (data.success) {
document.getElementById('result-strokes').value = '';
// Refresh leaderboard
htmx.ajax('GET', '/partials/tour-leaderboard/' + code, { target: '#leaderboard-container' });
} else {
alert(data.error || 'Failed to record result');
}
} catch (err) {
console.error('Error recording result:', err);
alert('Failed to record result');
}
}
+169
View File
@@ -0,0 +1,169 @@
var coursesData = [];
async function loadCourses() {
try {
var res = await fetch('/api/tours/courses-with-layouts');
coursesData = await res.json();
updateCourseDropdowns();
} catch (err) {
console.error('Failed to load courses:', err);
}
}
function populateSelectWithCourses(select) {
select.textContent = '';
var defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'Select a course...';
select.appendChild(defaultOpt);
coursesData.forEach(function(course) {
var opt = document.createElement('option');
opt.value = course.id;
opt.textContent = course.name + ' (' + course.city + ')';
select.appendChild(opt);
});
}
function updateCourseDropdowns() {
document.querySelectorAll('.course-select').forEach(function(select) {
var currentValue = select.value;
populateSelectWithCourses(select);
select.value = currentValue;
});
}
function onCourseChange(courseSelect) {
var index = courseSelect.dataset.index;
var layoutSelect = document.querySelector('.layout-select[data-index="' + index + '"]');
var courseId = parseInt(courseSelect.value);
layoutSelect.textContent = '';
layoutSelect.disabled = true;
var defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'Select layout...';
layoutSelect.appendChild(defaultOpt);
if (!courseId) return;
var course = coursesData.find(function(c) { return c.id === courseId; });
if (!course) return;
course.layouts.forEach(function(layout) {
var opt = document.createElement('option');
opt.value = layout.id;
opt.textContent = layout.name + ' (Par ' + layout.par + ')';
layoutSelect.appendChild(opt);
});
layoutSelect.disabled = false;
}
var courseIndex = 1;
function addCourseEntry() {
var container = document.getElementById('course-selector');
var entry = document.createElement('div');
entry.className = 'course-entry';
var courseSelect = document.createElement('select');
courseSelect.className = 'input course-select';
courseSelect.dataset.index = courseIndex;
populateSelectWithCourses(courseSelect);
courseSelect.addEventListener('change', function() { onCourseChange(courseSelect); });
var layoutSelect = document.createElement('select');
layoutSelect.className = 'input layout-select';
layoutSelect.dataset.index = courseIndex;
layoutSelect.disabled = true;
var defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'Select course first';
layoutSelect.appendChild(defaultOpt);
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-remove';
removeBtn.type = 'button';
var icon = document.createElement('i');
icon.className = 'fas fa-times';
removeBtn.appendChild(icon);
removeBtn.addEventListener('click', function() { entry.remove(); });
entry.appendChild(courseSelect);
entry.appendChild(layoutSelect);
entry.appendChild(removeBtn);
container.appendChild(entry);
courseIndex++;
}
async function createTour() {
var name = document.getElementById('tour-name').value.trim();
var startDate = document.getElementById('tour-start').value;
var endDate = document.getElementById('tour-end').value;
if (!name || !startDate || !endDate) {
alert('Please fill in name and dates');
return;
}
var courses = [];
document.querySelectorAll('.course-entry').forEach(function(entry) {
var courseId = entry.querySelector('.course-select').value;
var layoutId = entry.querySelector('.layout-select').value;
if (courseId && layoutId) {
courses.push({ courseId: parseInt(courseId), layoutId: parseInt(layoutId) });
}
});
if (courses.length === 0) {
alert('Please select at least one course with a layout');
return;
}
var btn = document.getElementById('create-tour-btn');
btn.disabled = true;
try {
var res = await fetch('/api/tours', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, startDate: startDate, endDate: endDate, courses: courses })
});
var data = await res.json();
if (data.success) {
var link = document.getElementById('tour-link');
link.href = '/tours/' + data.code;
link.textContent = window.location.origin + '/tours/' + data.code;
document.getElementById('tour-created').style.display = '';
} else {
alert(data.error || 'Failed to create tour');
}
} catch (err) {
console.error('Error creating tour:', err);
alert('Failed to create tour');
} finally {
btn.disabled = false;
}
}
function goToTour() {
var code = document.getElementById('tour-code').value.trim().toUpperCase();
if (code) {
window.location.href = '/tours/' + code;
}
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
loadCourses();
// Delegate change events for course selects
document.getElementById('course-selector').addEventListener('change', function(e) {
if (e.target.classList.contains('course-select')) {
onCourseChange(e.target);
}
});
});
+2
View File
@@ -4,6 +4,7 @@ const path = require('path');
const { initializeDatabase, checkAndPopulateDatabase } = require('./src/db'); const { initializeDatabase, checkAndPopulateDatabase } = require('./src/db');
const playerRoutes = require('./src/routes/players'); const playerRoutes = require('./src/routes/players');
const courseRoutes = require('./src/routes/courses'); const courseRoutes = require('./src/routes/courses');
const tourRoutes = require('./src/routes/tours');
const pageRoutes = require('./src/routes/pages'); const pageRoutes = require('./src/routes/pages');
const logger = require('./src/logger'); const logger = require('./src/logger');
@@ -17,6 +18,7 @@ app.use(express.json());
app.use(playerRoutes); app.use(playerRoutes);
app.use(courseRoutes); app.use(courseRoutes);
app.use(tourRoutes);
app.use(pageRoutes); app.use(pageRoutes);
initializeDatabase().then(async () => { initializeDatabase().then(async () => {
+48
View File
@@ -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(` db.run(`
CREATE TABLE IF NOT EXISTS layouts ( CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+164
View File
@@ -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
};
+4
View File
@@ -14,4 +14,8 @@ router.get('/courses.html', (req, res) => {
res.redirect('/courses'); res.redirect('/courses');
}); });
router.get('/tours', (req, res) => {
res.render('tours');
});
module.exports = router; module.exports = router;
+165
View File
@@ -0,0 +1,165 @@
const express = require('express');
const router = express.Router();
const { getAllCoursesFromDB, getLayoutsForCourse } = require('../models/course');
const {
createTour, getTourByCode, addCourseToTour, getTourCourses,
getTourCourseById, joinTour, getPlayerInTour, recordResult
} = require('../models/tour');
const { generateTourCode, isTourActive, isTourFinished, calculateLeaderboard } = require('../services/tour-service');
const logger = require('../logger');
// Create a new tour
router.post('/api/tours', async (req, res) => {
try {
const { name, startDate, endDate, courses } = req.body;
if (!name || !startDate || !endDate || !courses || courses.length === 0) {
return res.status(400).json({ error: 'Name, dates, and at least one course are required' });
}
if (new Date(endDate) <= new Date(startDate)) {
return res.status(400).json({ error: 'End date must be after start date' });
}
const code = generateTourCode();
const tourId = await createTour(name, code, startDate, endDate);
for (const course of courses) {
await addCourseToTour(tourId, course.courseId, course.layoutId);
}
logger.info(`Tour created: "${name}" (${code}) with ${courses.length} courses`);
res.json({ success: true, code, message: `Tour "${name}" created` });
} catch (error) {
logger.error('Error creating tour:', error.message);
res.status(500).json({ error: 'Failed to create tour' });
}
});
// Join a tour
router.post('/api/tours/:code/join', async (req, res) => {
try {
const { code } = req.params;
const { pdgaNumber, playerName } = req.body;
if (!pdgaNumber || !playerName) {
return res.status(400).json({ error: 'PDGA number and name are required' });
}
const tour = await getTourByCode(code);
if (!tour) {
return res.status(404).json({ error: 'Tour not found' });
}
await joinTour(tour.id, pdgaNumber, playerName);
logger.info(`Player ${playerName} (${pdgaNumber}) joined tour ${code}`);
res.json({ success: true, message: `Joined tour "${tour.name}"` });
} catch (error) {
logger.error('Error joining tour:', error.message);
res.status(500).json({ error: 'Failed to join tour' });
}
});
// Record a result
router.post('/api/tours/:code/results', async (req, res) => {
try {
const { code } = req.params;
const { pdgaNumber, tourCourseId, totalStrokes } = req.body;
if (!pdgaNumber || !tourCourseId || !totalStrokes) {
return res.status(400).json({ error: 'PDGA number, course, and strokes are required' });
}
const tour = await getTourByCode(code);
if (!tour) {
return res.status(404).json({ error: 'Tour not found' });
}
// Verify tourCourseId belongs to this tour
const tourCourse = await getTourCourseById(tourCourseId);
if (!tourCourse || tourCourse.tour_id !== tour.id) {
return res.status(400).json({ error: 'Invalid course for this tour' });
}
if (!isTourActive(tour)) {
return res.status(400).json({ error: 'Tour is not active. Results can only be recorded during the tour period.' });
}
const player = await getPlayerInTour(tour.id, pdgaNumber);
if (!player) {
return res.status(403).json({ error: 'You must join the tour before recording results' });
}
await recordResult(tourCourseId, pdgaNumber, parseInt(totalStrokes));
logger.info(`Result recorded: ${pdgaNumber} scored ${totalStrokes} on course ${tourCourseId} in tour ${code}`);
res.json({ success: true, message: 'Result recorded' });
} catch (error) {
logger.error('Error recording result:', error.message);
res.status(500).json({ error: 'Failed to record result' });
}
});
// Get leaderboard partial (HTMX)
router.get('/partials/tour-leaderboard/:code', async (req, res) => {
try {
const tour = await getTourByCode(req.params.code);
if (!tour) {
return res.status(404).send('<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;
+81
View File
@@ -0,0 +1,81 @@
const crypto = require('crypto');
const { getTourCourses, getResultsForTour, getTourPlayers } = require('../models/tour');
function generateTourCode() {
return crypto.randomBytes(3).toString('hex').toUpperCase();
}
function isTourActive(tour) {
const now = new Date().toISOString().split('T')[0];
return now >= tour.start_date && now <= tour.end_date;
}
function isTourFinished(tour) {
const now = new Date().toISOString().split('T')[0];
return now > tour.end_date;
}
async function calculateLeaderboard(tourId) {
const courses = await getTourCourses(tourId);
const results = await getResultsForTour(tourId);
const players = await getTourPlayers(tourId);
// Group results by course
const resultsByCourse = {};
for (const course of courses) {
resultsByCourse[course.tour_course_id] = results
.filter(r => r.tour_course_id === course.tour_course_id)
.sort((a, b) => a.total_strokes - b.total_strokes);
}
// Calculate points per course
const playerPoints = {};
for (const player of players) {
playerPoints[player.pdga_number] = {
player_name: player.player_name,
pdga_number: player.pdga_number,
courses: {},
total_points: 0
};
}
for (const course of courses) {
const courseResults = resultsByCourse[course.tour_course_id] || [];
const numPlayers = courseResults.length;
let rank = 1;
for (let i = 0; i < courseResults.length; i++) {
// Check for ties with previous player
if (i > 0 && courseResults[i].total_strokes > courseResults[i - 1].total_strokes) {
rank = i + 1;
}
const points = Math.max(numPlayers - rank + 1, 1);
const r = courseResults[i];
const relativePar = r.total_strokes - course.par;
if (playerPoints[r.pdga_number]) {
playerPoints[r.pdga_number].courses[course.tour_course_id] = {
total_strokes: r.total_strokes,
relative_par: relativePar,
points: points,
rank: rank
};
playerPoints[r.pdga_number].total_points += points;
}
}
}
// Sort by total points descending
const leaderboard = Object.values(playerPoints)
.sort((a, b) => b.total_points - a.total_points);
return { courses, leaderboard };
}
module.exports = {
generateTourCode,
isTourActive,
isTourFinished,
calculateLeaderboard
};
+91
View File
@@ -0,0 +1,91 @@
<% 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 courseOptionsHtml = '';
courses.forEach(function(c) {
courseOptionsHtml += '<option value="' + c.tour_course_id + '">'
+ c.course_name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+ ' - '
+ c.layout_name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+ ' (Par ' + c.par + ')'
+ '</option>';
});
var escapedCode = tour.code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
var escapedStartDate = tour.start_date.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var escapedEndDate = tour.end_date.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
%>
<% 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> ${escapedStartDate} &mdash; ${escapedEndDate}</span>
<span class="tour-code-label"><i class="fas fa-key"></i> ${escapedCode}</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>
${courseOptionsHtml}
</select>
<input type="number" class="input" id="result-strokes" placeholder="Total strokes" style="width: 140px;" min="1" />
<button class="btn" onclick="recordResult('${escapedCode}')">
<i class="fas fa-save"></i> Save
</button>
</div>
</div>
<div id="leaderboard-container"
hx-get="/partials/tour-leaderboard/${escapedCode}"
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("' + escapedCode + '");',
body: body
}) %>
<% } %>
+67
View File
@@ -0,0 +1,67 @@
<% 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
}) %>
+1
View File
@@ -1,4 +1,5 @@
<nav class="app-nav"> <nav class="app-nav">
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a> <a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a> <a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
<a href="/tours" class="<%= activePage === 'tours' ? 'active' : '' %>">Tours</a>
</nav> </nav>
+52
View File
@@ -0,0 +1,52 @@
<% 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>
<% } %>