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;
|
||||
}
|
||||
|
||||
/* ── 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) {
|
||||
const icon = document.querySelector(`#row-${courseId} .refresh-icon`);
|
||||
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 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');
|
||||
|
||||
@@ -18,7 +17,6 @@ app.use(express.json());
|
||||
|
||||
app.use(playerRoutes);
|
||||
app.use(courseRoutes);
|
||||
app.use(tourRoutes);
|
||||
app.use(pageRoutes);
|
||||
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS layouts (
|
||||
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 = {
|
||||
saveCourseToDB,
|
||||
getAllCoursesFromDB,
|
||||
saveLayoutToDB,
|
||||
getLayoutsForCourse,
|
||||
getOrCreateLayout,
|
||||
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 router = express.Router();
|
||||
const { db } = require('../db');
|
||||
const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating, saveCourseToDB, getOrCreateLayout } = require('../models/course');
|
||||
const { searchTjingCourses, getTjingCourse } = require('../scrapers/tjing');
|
||||
const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating } = require('../models/course');
|
||||
const { launchBrowser } = require('../scrapers/browser');
|
||||
const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer');
|
||||
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;
|
||||
|
||||
@@ -14,8 +14,4 @@ router.get('/courses.html', (req, res) => {
|
||||
res.redirect('/courses');
|
||||
});
|
||||
|
||||
router.get('/tours', (req, res) => {
|
||||
res.render('tours');
|
||||
});
|
||||
|
||||
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 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="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">
|
||||
<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>
|
||||
@@ -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