Merge pull request #5 from shcizo/feat/async-tours
feat: async tour system
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -14,4 +14,8 @@ router.get('/courses.html', (req, res) => {
|
||||
res.redirect('/courses');
|
||||
});
|
||||
|
||||
router.get('/tours', (req, res) => {
|
||||
res.render('tours');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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('<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 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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
+ ' - '
|
||||
+ c.layout_name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
+ ' (Par ' + c.par + ')'
|
||||
+ '</option>';
|
||||
});
|
||||
|
||||
var escapedCode = tour.code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
var escapedStartDate = tour.start_date.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
var escapedEndDate = tour.end_date.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
%>
|
||||
|
||||
<% 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} — ${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
|
||||
}) %>
|
||||
<% } %>
|
||||
@@ -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,4 +1,5 @@
|
||||
<nav class="app-nav">
|
||||
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
|
||||
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
|
||||
<a href="/tours" class="<%= activePage === 'tours' ? 'active' : '' %>">Tours</a>
|
||||
</nav>
|
||||
@@ -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>
|
||||
<% } %>
|
||||
Reference in New Issue
Block a user