refactor: remove tour feature and Tjing import
Release Please / release-please (push) Waiting to run
Release Please / docker (push) Blocked by required conditions

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:
Samuel Enocsson
2026-03-20 15:05:14 +01:00
parent b4206d9865
commit 6e05d3014d
21 changed files with 1 additions and 3056 deletions
File diff suppressed because it is too large Load Diff
-33
View File
@@ -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;
}
-211
View File
@@ -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;
}
}
-82
View File
@@ -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');
-91
View File
@@ -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');
}
}
-255
View File
@@ -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);
}
});
});
-145
View File
@@ -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);
});
-2
View File
@@ -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 () => {
-48
View File
@@ -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,
-23
View File
@@ -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
}; };
-164
View File
@@ -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
View File
@@ -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;
-4
View File
@@ -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;
-174
View File
@@ -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;
-95
View File
@@ -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
};
-82
View File
@@ -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
};
-17
View File
@@ -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>
`; %> `; %>
-91
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+ ' - '
+ c.layout_name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+ ' (Par ' + c.par + ')'
+ '</option>';
});
var escapedCode = tour.code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
var escapedStartDate = tour.start_date.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var escapedEndDate = tour.end_date.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
%>
<% var body = `
<div class="tour-header">
<div class="tour-info">
<div class="tour-meta">
${statusBadge}
<span class="tour-dates"><i class="fas fa-calendar"></i> ${escapedStartDate} &mdash; ${escapedEndDate}</span>
<span class="tour-code-label"><i class="fas fa-key"></i> ${escapedCode}</span>
</div>
</div>
</div>
<div class="card-section" id="join-section">
<h3>Join This Tour</h3>
<div class="card-section-form">
<input type="text" class="input" id="pdga-number" placeholder="PDGA Number" style="width: 160px;" />
<input type="text" class="input" id="player-name" placeholder="Your Name" style="width: 200px;" />
<button class="btn" onclick="joinTour()">
<i class="fas fa-user-plus"></i> Join
</button>
</div>
</div>
<div class="card-section" id="result-section" style="display: none;">
<h3>Record Result</h3>
<div class="card-section-form">
<select class="input" id="result-course" style="width: 280px;">
<option value="">Select course...</option>
${courseOptionsHtml}
</select>
<input type="number" class="input" id="result-strokes" placeholder="Total strokes" style="width: 140px;" min="1" />
<button class="btn" onclick="recordResult('${escapedCode}')">
<i class="fas fa-save"></i> Save
</button>
</div>
</div>
<div id="leaderboard-container"
hx-get="/partials/tour-leaderboard/${escapedCode}"
hx-trigger="load">
<div class="loading">Loading leaderboard...</div>
</div>
`; %>
<%- include('../partials/layout', {
title: tour.name + ' - PDGA Tours',
heading: tour.name,
activePage: 'tours',
cssFiles: ['tours.css'],
jsFiles: ['tour.js'],
initScript: 'initTour("' + escapedCode + '");',
body: body
}) %>
<% } %>
-67
View File
@@ -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
View File
@@ -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>
-52
View File
@@ -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>
<% } %>