refactor: remove tour feature and Tjing import
Tour functionality has moved to its own project (HyzrTour). Removes all tour-related code, Tjing integration, and associated views/styles/scripts. Keeps the saveCourseToDB ON CONFLICT fix.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -132,36 +132,3 @@
|
|||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tjing Import ────────────────────────────── */
|
|
||||||
|
|
||||||
.tjing-result {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 14px;
|
|
||||||
margin: 6px 0;
|
|
||||||
background: var(--surface-2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tjing-result-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tjing-result-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tjing-result-address {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
#tjing-results {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,88 +54,6 @@ async function scrapeCourses() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchTjing() {
|
|
||||||
var input = document.getElementById('tjing-search');
|
|
||||||
var query = input.value.trim();
|
|
||||||
if (query.length < 2) return;
|
|
||||||
|
|
||||||
var btn = document.getElementById('tjing-search-btn');
|
|
||||||
btn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
var response = await fetch('/api/tjing/search?q=' + encodeURIComponent(query));
|
|
||||||
var courses = await response.json();
|
|
||||||
var container = document.getElementById('tjing-results');
|
|
||||||
container.textContent = '';
|
|
||||||
|
|
||||||
if (courses.length === 0) {
|
|
||||||
var p = document.createElement('p');
|
|
||||||
p.className = 'no-layouts';
|
|
||||||
p.textContent = 'No courses found on Tjing';
|
|
||||||
container.appendChild(p);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
courses.forEach(function(course) {
|
|
||||||
var item = document.createElement('div');
|
|
||||||
item.className = 'tjing-result';
|
|
||||||
|
|
||||||
var info = document.createElement('div');
|
|
||||||
info.className = 'tjing-result-info';
|
|
||||||
|
|
||||||
var name = document.createElement('span');
|
|
||||||
name.className = 'tjing-result-name';
|
|
||||||
name.textContent = course.name;
|
|
||||||
|
|
||||||
var addr = document.createElement('span');
|
|
||||||
addr.className = 'tjing-result-address';
|
|
||||||
addr.textContent = course.address || '';
|
|
||||||
|
|
||||||
info.appendChild(name);
|
|
||||||
info.appendChild(addr);
|
|
||||||
|
|
||||||
var importBtn = document.createElement('button');
|
|
||||||
importBtn.className = 'btn';
|
|
||||||
importBtn.textContent = 'Import';
|
|
||||||
importBtn.addEventListener('click', function() { importFromTjing(course.id, importBtn); });
|
|
||||||
|
|
||||||
item.appendChild(info);
|
|
||||||
item.appendChild(importBtn);
|
|
||||||
container.appendChild(item);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching Tjing:', error);
|
|
||||||
alert('Failed to search Tjing');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importFromTjing(tjingId, btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Importing...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
var response = await fetch('/api/tjing/import/' + tjingId, { method: 'POST' });
|
|
||||||
var data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
btn.textContent = 'Imported!';
|
|
||||||
btn.style.background = 'var(--green)';
|
|
||||||
htmx.ajax('GET', '/partials/course-table', '#courses-table');
|
|
||||||
} else {
|
|
||||||
alert(data.error || 'Failed to import');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Import';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error importing from Tjing:', error);
|
|
||||||
alert('Failed to import course');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Import';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrapeLayouts(courseId, courseName) {
|
async function scrapeLayouts(courseId, courseName) {
|
||||||
const icon = document.querySelector(`#row-${courseId} .refresh-icon`);
|
const icon = document.querySelector(`#row-${courseId} .refresh-icon`);
|
||||||
icon.classList.add('spinning');
|
icon.classList.add('spinning');
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Repairs orphaned layouts by reassigning them to the correct course.
|
|
||||||
//
|
|
||||||
// The problem: saveCourseToDB used INSERT OR REPLACE which deletes and
|
|
||||||
// re-inserts courses with new IDs. Layouts still reference the old IDs.
|
|
||||||
//
|
|
||||||
// Strategy: For each orphaned layout, find a course that has a matching
|
|
||||||
// layout (same name + par) from a valid course_id. If no match, try to
|
|
||||||
// find the course by looking at the gap between old and new course IDs
|
|
||||||
// (courses were likely re-scraped in the same order).
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const dbPath = process.env.DB_PATH || './ratings.db';
|
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const db = new sqlite3.Database(dbPath);
|
|
||||||
|
|
||||||
function all(sql, params) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.all(sql, params || [], (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(sql, params) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.run(sql, params || [], function(err) {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(this.changes);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function repair() {
|
|
||||||
// Find all orphaned layouts (course_id not in courses table)
|
|
||||||
const orphaned = await all(`
|
|
||||||
SELECT l.id, l.course_id, l.name, l.par, l.mean_rating, l.rating_count, l.last_calculated, l.last_played
|
|
||||||
FROM layouts l
|
|
||||||
LEFT JOIN courses c ON l.course_id = c.id
|
|
||||||
WHERE c.id IS NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('Orphaned layouts:', orphaned.length);
|
|
||||||
|
|
||||||
if (orphaned.length === 0) {
|
|
||||||
console.log('Nothing to repair!');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all valid courses
|
|
||||||
const courses = await all('SELECT id, name, link FROM courses ORDER BY id');
|
|
||||||
console.log('Valid courses:', courses.length);
|
|
||||||
|
|
||||||
// Get all valid layouts (to avoid duplicates)
|
|
||||||
const validLayouts = await all(`
|
|
||||||
SELECT l.course_id, l.name, l.par
|
|
||||||
FROM layouts l
|
|
||||||
JOIN courses c ON l.course_id = c.id
|
|
||||||
`);
|
|
||||||
|
|
||||||
const validSet = new Set(validLayouts.map(l => l.course_id + '|' + l.name + '|' + l.par));
|
|
||||||
|
|
||||||
// Group orphaned layouts by old course_id
|
|
||||||
const byOldId = {};
|
|
||||||
for (const l of orphaned) {
|
|
||||||
if (!byOldId[l.course_id]) byOldId[l.course_id] = [];
|
|
||||||
byOldId[l.course_id].push(l);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Unique orphaned course_ids:', Object.keys(byOldId).length);
|
|
||||||
|
|
||||||
// Try to match: old course_ids likely map to current courses
|
|
||||||
// If courses were re-scraped in order, old_id and new_id have a fixed offset
|
|
||||||
// Let's try to find the offset by checking if shifting all old_ids by some value matches existing courses
|
|
||||||
const oldIds = Object.keys(byOldId).map(Number).sort((a, b) => a - b);
|
|
||||||
const courseIds = courses.map(c => c.id);
|
|
||||||
const courseIdSet = new Set(courseIds);
|
|
||||||
|
|
||||||
// Try different offsets
|
|
||||||
let bestOffset = 0;
|
|
||||||
let bestMatches = 0;
|
|
||||||
|
|
||||||
for (let offset = -1000; offset <= 1000; offset++) {
|
|
||||||
let matches = 0;
|
|
||||||
for (const oldId of oldIds) {
|
|
||||||
if (courseIdSet.has(oldId + offset)) matches++;
|
|
||||||
}
|
|
||||||
if (matches > bestMatches) {
|
|
||||||
bestMatches = matches;
|
|
||||||
bestOffset = offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Best offset:', bestOffset, '(matches', bestMatches, 'of', oldIds.length, 'orphaned course_ids)');
|
|
||||||
|
|
||||||
let repaired = 0;
|
|
||||||
let skippedDuplicate = 0;
|
|
||||||
let noMatch = 0;
|
|
||||||
|
|
||||||
for (const oldId of oldIds) {
|
|
||||||
const newId = oldId + bestOffset;
|
|
||||||
const layouts = byOldId[oldId];
|
|
||||||
|
|
||||||
if (!courseIdSet.has(newId)) {
|
|
||||||
noMatch += layouts.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const layout of layouts) {
|
|
||||||
const key = newId + '|' + layout.name + '|' + layout.par;
|
|
||||||
if (validSet.has(key)) {
|
|
||||||
// Duplicate — delete the orphaned one
|
|
||||||
await run('DELETE FROM layouts WHERE id = ?', [layout.id]);
|
|
||||||
skippedDuplicate++;
|
|
||||||
} else {
|
|
||||||
// Reassign to correct course
|
|
||||||
await run('UPDATE layouts SET course_id = ? WHERE id = ?', [newId, layout.id]);
|
|
||||||
validSet.add(key);
|
|
||||||
repaired++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nResults:');
|
|
||||||
console.log(' Repaired:', repaired);
|
|
||||||
console.log(' Deleted (duplicates):', skippedDuplicate);
|
|
||||||
console.log(' No match found:', noMatch);
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
const remaining = await all(`
|
|
||||||
SELECT COUNT(*) as c FROM layouts l
|
|
||||||
LEFT JOIN courses c ON l.course_id = c.id
|
|
||||||
WHERE c.id IS NULL
|
|
||||||
`);
|
|
||||||
console.log(' Remaining orphans:', remaining[0].c);
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
repair().catch(err => {
|
|
||||||
console.error('Error:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,6 @@ 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');
|
||||||
|
|
||||||
@@ -18,7 +17,6 @@ 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 () => {
|
||||||
|
|||||||
@@ -92,54 +92,6 @@ 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,
|
||||||
|
|||||||
@@ -70,33 +70,10 @@ 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 = {
|
module.exports = {
|
||||||
saveCourseToDB,
|
saveCourseToDB,
|
||||||
getAllCoursesFromDB,
|
getAllCoursesFromDB,
|
||||||
saveLayoutToDB,
|
saveLayoutToDB,
|
||||||
getLayoutsForCourse,
|
getLayoutsForCourse,
|
||||||
getOrCreateLayout,
|
|
||||||
updateLayoutRating
|
updateLayoutRating
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
+1
-53
@@ -1,8 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { db } = require('../db');
|
const { db } = require('../db');
|
||||||
const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating, saveCourseToDB, getOrCreateLayout } = require('../models/course');
|
const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating } = require('../models/course');
|
||||||
const { searchTjingCourses, getTjingCourse } = require('../scrapers/tjing');
|
|
||||||
const { launchBrowser } = require('../scrapers/browser');
|
const { launchBrowser } = require('../scrapers/browser');
|
||||||
const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer');
|
const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer');
|
||||||
const logger = require('../logger');
|
const logger = require('../logger');
|
||||||
@@ -370,55 +369,4 @@ router.post('/api/scrape-event-results/:courseId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search Tjing for courses
|
|
||||||
router.get('/api/tjing/search', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { q } = req.query;
|
|
||||||
if (!q || q.trim().length < 2) {
|
|
||||||
return res.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const courses = await searchTjingCourses(q.trim());
|
|
||||||
res.json(courses);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, 'Error searching Tjing');
|
|
||||||
res.status(500).json({ error: 'Failed to search Tjing' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import a course from Tjing
|
|
||||||
router.post('/api/tjing/import/:tjingId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { tjingId } = req.params;
|
|
||||||
const courseData = await getTjingCourse(tjingId);
|
|
||||||
|
|
||||||
if (courseData.layouts.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'Course has no published layouts on Tjing' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const courseId = await saveCourseToDB({
|
|
||||||
name: courseData.name,
|
|
||||||
link: 'https://tjing.se/courses/' + tjingId,
|
|
||||||
city: courseData.address || ''
|
|
||||||
});
|
|
||||||
|
|
||||||
let layoutCount = 0;
|
|
||||||
for (const layout of courseData.layouts) {
|
|
||||||
await getOrCreateLayout(courseId, layout.name, layout.par);
|
|
||||||
layoutCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Imported from Tjing: "${courseData.name}" with ${layoutCount} layouts`);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: `Imported "${courseData.name}" with ${layoutCount} layouts`,
|
|
||||||
courseName: courseData.name,
|
|
||||||
layoutCount
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ err: error }, 'Error importing from Tjing');
|
|
||||||
res.status(500).json({ error: 'Failed to import course from Tjing' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -14,8 +14,4 @@ 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;
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
const logger = require('../logger');
|
|
||||||
|
|
||||||
const TJING_API = 'https://api.tjing.se/graphql';
|
|
||||||
|
|
||||||
async function searchTjingCourses(searchTerm) {
|
|
||||||
const query = `{
|
|
||||||
courses(first: 10, filter: { search: "${searchTerm.replace(/"/g, '\\"')}" }) {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
address
|
|
||||||
type
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const response = await fetch(TJING_API, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ query })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Tjing API returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.errors) {
|
|
||||||
throw new Error(data.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.data.courses || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getTjingCourse(courseId) {
|
|
||||||
const query = `{
|
|
||||||
course(courseId: "${courseId.replace(/"/g, '\\"')}") {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
address
|
|
||||||
layouts {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
published
|
|
||||||
latestVersion {
|
|
||||||
holes {
|
|
||||||
number
|
|
||||||
par
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const response = await fetch(TJING_API, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ query })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Tjing API returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.errors) {
|
|
||||||
throw new Error(data.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const course = data.data.course;
|
|
||||||
if (!course) {
|
|
||||||
throw new Error('Course not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total par per layout from holes
|
|
||||||
const layouts = (course.layouts || [])
|
|
||||||
.filter(l => l.published && l.latestVersion && l.latestVersion.holes.length > 0)
|
|
||||||
.map(l => ({
|
|
||||||
name: l.name,
|
|
||||||
par: l.latestVersion.holes.reduce((sum, h) => sum + h.par, 0),
|
|
||||||
holes: l.latestVersion.holes.length
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: course.name,
|
|
||||||
address: course.address,
|
|
||||||
tjingId: course.id,
|
|
||||||
layouts
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
searchTjingCourses,
|
|
||||||
getTjingCourse
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
@@ -19,23 +19,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-section">
|
|
||||||
<h3>Import from Tjing</h3>
|
|
||||||
<div class="card-section-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input"
|
|
||||||
id="tjing-search"
|
|
||||||
placeholder="Search Tjing by name or city..."
|
|
||||||
style="width: 340px;"
|
|
||||||
/>
|
|
||||||
<button class="btn" onclick="searchTjing()" id="tjing-search-btn">
|
|
||||||
<i class="fas fa-search"></i> Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="tjing-results"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="loading" class="loading" style="display: none;">Loading courses...</div>
|
<div id="loading" class="loading" style="display: none;">Loading courses...</div>
|
||||||
<div id="courses-table" hx-get="/partials/course-table" hx-trigger="load"></div>
|
<div id="courses-table" hx-get="/partials/course-table" hx-trigger="load"></div>
|
||||||
`; %>
|
`; %>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<% 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
|
|
||||||
}) %>
|
|
||||||
<% } %>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<% 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,5 +1,4 @@
|
|||||||
<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>
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<% 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