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
+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);
}
});
});