Extract inline CSS/JS, add EJS templates with shared layout
- Extract CSS into public/css/{shared,players,courses}.css
- Extract JS into public/js/{chart,tooltips,progress,players,courses}.js
- Consolidate 5 duplicated tooltip blocks into setupTooltip() helper
- Add EJS view engine with layout partial and nav partial
- Convert HTML pages to EJS templates (index.ejs, courses.ejs)
- Add /courses route with redirect from /courses.html
- Remove old monolithic HTML files (1478 + 612 lines)
This commit is contained in:
-612
@@ -1,612 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PDGA Courses - Sweden</title>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
body {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: white;
|
|
||||||
padding: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.nav-links {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.nav-links a {
|
|
||||||
margin: 0 15px;
|
|
||||||
color: #007bff;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.nav-links a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.controls {
|
|
||||||
text-align: right;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
.btn:disabled {
|
|
||||||
background-color: #6c757d;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.search-container {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
padding: 10px 15px;
|
|
||||||
font-size: 16px;
|
|
||||||
border: 2px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
.search-input:focus {
|
|
||||||
border-color: #007bff;
|
|
||||||
}
|
|
||||||
.search-results-info {
|
|
||||||
text-align: center;
|
|
||||||
margin: 10px 0;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
th, td {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
table {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
th, td {
|
|
||||||
padding: 8px 4px;
|
|
||||||
}
|
|
||||||
.mobile-hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
th {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
tr:hover {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
.expandable-row {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.expandable-row:hover {
|
|
||||||
background-color: #e3f2fd;
|
|
||||||
}
|
|
||||||
.expanded-content {
|
|
||||||
display: none;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-top: 2px solid #007bff;
|
|
||||||
}
|
|
||||||
.expanded-content td {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.layouts-container {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
.layout-item {
|
|
||||||
padding: 10px;
|
|
||||||
margin: 5px 0;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.layout-name {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.layout-par {
|
|
||||||
color: #007bff;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.no-layouts {
|
|
||||||
text-align: center;
|
|
||||||
color: #999;
|
|
||||||
font-style: italic;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inactive-layouts-accordion {
|
|
||||||
margin-top: 15px;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-header {
|
|
||||||
padding: 12px 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #e9ecef;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-header:hover {
|
|
||||||
background-color: #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-header-text {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-icon {
|
|
||||||
transition: transform 0.3s;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-icon.expanded {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-content {
|
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.3s ease-out;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-content.expanded {
|
|
||||||
max-height: 2000px;
|
|
||||||
padding: 10px;
|
|
||||||
transition: max-height 0.5s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-item.inactive {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.refresh-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.6;
|
|
||||||
margin-left: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6c757d;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.refresh-icon:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: #007bff;
|
|
||||||
background-color: rgba(0, 123, 255, 0.1);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
.refresh-icon.spinning {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
color: #007bff;
|
|
||||||
opacity: 1;
|
|
||||||
background-color: rgba(0, 123, 255, 0.1);
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>PDGA Courses - Sweden</h1>
|
|
||||||
|
|
||||||
<div class="nav-links">
|
|
||||||
<a href="/">Player Ratings</a>
|
|
||||||
<a href="/courses.html" style="color: #333;">Courses</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="search-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="search-input"
|
|
||||||
id="course-search"
|
|
||||||
placeholder="Search courses by name or city..."
|
|
||||||
oninput="searchCourses()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div id="search-results-info" class="search-results-info"></div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<button class="btn" onclick="scrapeCourses()" id="scrape-courses-btn">
|
|
||||||
<i class="fas fa-sync-alt"></i> Scrape Courses
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="loading" class="loading" style="display: none;">Loading courses...</div>
|
|
||||||
<div id="courses-table"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let allCourses = [];
|
|
||||||
|
|
||||||
async function loadCourses() {
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
const tableDiv = document.getElementById('courses-table');
|
|
||||||
|
|
||||||
loading.style.display = 'block';
|
|
||||||
tableDiv.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/courses');
|
|
||||||
allCourses = await response.json();
|
|
||||||
|
|
||||||
loading.style.display = 'none';
|
|
||||||
displayCourses(allCourses);
|
|
||||||
updateSearchInfo(allCourses.length, allCourses.length);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading courses:', error);
|
|
||||||
loading.style.display = 'none';
|
|
||||||
tableDiv.innerHTML = '<p>Error loading courses. Please try again.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchCourses() {
|
|
||||||
const searchInput = document.getElementById('course-search');
|
|
||||||
const searchTerm = searchInput.value.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (!searchTerm) {
|
|
||||||
displayCourses(allCourses);
|
|
||||||
updateSearchInfo(allCourses.length, allCourses.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = allCourses.filter(course => {
|
|
||||||
return course.name.toLowerCase().includes(searchTerm) ||
|
|
||||||
course.city.toLowerCase().includes(searchTerm);
|
|
||||||
});
|
|
||||||
|
|
||||||
displayCourses(filtered);
|
|
||||||
updateSearchInfo(filtered.length, allCourses.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSearchInfo(showing, total) {
|
|
||||||
const infoDiv = document.getElementById('search-results-info');
|
|
||||||
if (showing === total) {
|
|
||||||
infoDiv.textContent = `Showing all ${total} courses`;
|
|
||||||
} else {
|
|
||||||
infoDiv.textContent = `Showing ${showing} of ${total} courses`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayCourses(courses) {
|
|
||||||
const tableDiv = document.getElementById('courses-table');
|
|
||||||
|
|
||||||
if (courses.length === 0) {
|
|
||||||
tableDiv.innerHTML = '<p>No courses found. Click "Scrape Courses" to load Swedish courses from PDGA.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tableHTML = `
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Course Name</th>
|
|
||||||
<th class="mobile-hide">City</th>
|
|
||||||
<th class="mobile-hide">Last Updated</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
`;
|
|
||||||
|
|
||||||
courses.forEach(course => {
|
|
||||||
const lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
|
|
||||||
tableHTML += `
|
|
||||||
<tr id="row-${course.id}" class="expandable-row" onclick="toggleCourseLayouts(${course.id})">
|
|
||||||
<td>
|
|
||||||
<a href="${course.link}" target="_blank" onclick="event.stopPropagation()">${course.name}</a>
|
|
||||||
<div class="mobile-only" style="font-size: 11px; color: #999; margin-top: 2px;">${course.city}</div>
|
|
||||||
</td>
|
|
||||||
<td class="mobile-hide">${course.city}</td>
|
|
||||||
<td class="mobile-hide">${lastUpdated}</td>
|
|
||||||
<td>
|
|
||||||
<i class="fas fa-sync-alt refresh-icon" onclick="scrapeLayouts(${course.id}, '${course.name.replace(/'/g, "\\'")}'); event.stopPropagation();" title="Scrape layouts for this course"></i>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr id="layouts-${course.id}" class="expanded-content">
|
|
||||||
<td colspan="4">
|
|
||||||
<div class="layouts-container" id="layouts-container-${course.id}">
|
|
||||||
<div class="no-layouts">Click to load layouts...</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
tableHTML += `
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
|
|
||||||
tableDiv.innerHTML = tableHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAccordion(accordionId) {
|
|
||||||
const content = document.getElementById(accordionId);
|
|
||||||
const icon = document.getElementById(`${accordionId}-icon`);
|
|
||||||
|
|
||||||
if (content.classList.contains('expanded')) {
|
|
||||||
content.classList.remove('expanded');
|
|
||||||
icon.classList.remove('expanded');
|
|
||||||
} else {
|
|
||||||
content.classList.add('expanded');
|
|
||||||
icon.classList.add('expanded');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleCourseLayouts(courseId) {
|
|
||||||
const layoutsRow = document.getElementById(`layouts-${courseId}`);
|
|
||||||
const layoutsContainer = document.getElementById(`layouts-container-${courseId}`);
|
|
||||||
|
|
||||||
if (layoutsRow.style.display === 'table-row') {
|
|
||||||
layoutsRow.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
layoutsRow.style.display = 'table-row';
|
|
||||||
|
|
||||||
// Check if layouts are already loaded
|
|
||||||
if (layoutsContainer.dataset.loaded === 'true') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
layoutsContainer.innerHTML = '<div class="no-layouts">Loading layouts...</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/layouts/${courseId}`);
|
|
||||||
const layouts = await response.json();
|
|
||||||
|
|
||||||
if (layouts.length > 0) {
|
|
||||||
// Calculate date threshold (365 days ago)
|
|
||||||
const oneYearAgo = new Date();
|
|
||||||
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
|
|
||||||
|
|
||||||
// Separate layouts into active and inactive
|
|
||||||
const activeLayouts = [];
|
|
||||||
const inactiveLayouts = [];
|
|
||||||
|
|
||||||
layouts.forEach(layout => {
|
|
||||||
if (layout.last_played) {
|
|
||||||
const lastPlayedDate = new Date(layout.last_played);
|
|
||||||
if (lastPlayedDate >= oneYearAgo) {
|
|
||||||
activeLayouts.push(layout);
|
|
||||||
} else {
|
|
||||||
inactiveLayouts.push(layout);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Never played -> inactive
|
|
||||||
inactiveLayouts.push(layout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let layoutsHTML = '<h4 style="margin-top: 0;">Layouts:</h4>';
|
|
||||||
|
|
||||||
// Render active layouts
|
|
||||||
if (activeLayouts.length > 0) {
|
|
||||||
activeLayouts.forEach(layout => {
|
|
||||||
const ratingDisplay = layout.mean_rating ?
|
|
||||||
`<span style="color: #28a745; font-weight: bold; margin-left: 10px;">Rating: ${layout.mean_rating}</span>` :
|
|
||||||
'';
|
|
||||||
const dateDisplay = layout.last_played ?
|
|
||||||
`<span style="color: #6c757d; font-size: 12px; margin-left: 10px;">Last played: ${new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>` :
|
|
||||||
'';
|
|
||||||
layoutsHTML += `
|
|
||||||
<div class="layout-item">
|
|
||||||
<div>
|
|
||||||
<span class="layout-name">${layout.name}</span>
|
|
||||||
${dateDisplay}
|
|
||||||
</div>
|
|
||||||
<span class="layout-par">Par ${layout.par}${ratingDisplay}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render inactive layouts in accordion
|
|
||||||
if (inactiveLayouts.length > 0) {
|
|
||||||
const accordionId = `accordion-${courseId}`;
|
|
||||||
layoutsHTML += `
|
|
||||||
<div class="inactive-layouts-accordion">
|
|
||||||
<div class="accordion-header" onclick="toggleAccordion('${accordionId}')">
|
|
||||||
<span class="accordion-header-text">Inactive Layouts (${inactiveLayouts.length}) - Not played in last year</span>
|
|
||||||
<span class="accordion-icon" id="${accordionId}-icon">▼</span>
|
|
||||||
</div>
|
|
||||||
<div class="accordion-content" id="${accordionId}">
|
|
||||||
`;
|
|
||||||
|
|
||||||
inactiveLayouts.forEach(layout => {
|
|
||||||
const ratingDisplay = layout.mean_rating ?
|
|
||||||
`<span style="color: #28a745; font-weight: bold; margin-left: 10px;">Rating: ${layout.mean_rating}</span>` :
|
|
||||||
'';
|
|
||||||
const dateDisplay = layout.last_played ?
|
|
||||||
`<span style="color: #6c757d; font-size: 12px; margin-left: 10px;">Last played: ${new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>` :
|
|
||||||
`<span style="color: #dc3545; font-size: 12px; margin-left: 10px;">Never played</span>`;
|
|
||||||
layoutsHTML += `
|
|
||||||
<div class="layout-item inactive">
|
|
||||||
<div>
|
|
||||||
<span class="layout-name">${layout.name}</span>
|
|
||||||
${dateDisplay}
|
|
||||||
</div>
|
|
||||||
<span class="layout-par">Par ${layout.par}${ratingDisplay}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
layoutsHTML += `
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeLayouts.length === 0 && inactiveLayouts.length === 0) {
|
|
||||||
layoutsHTML = '<div class="no-layouts">No layouts found. Click the refresh icon to scrape layouts.</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
layoutsContainer.innerHTML = layoutsHTML;
|
|
||||||
layoutsContainer.dataset.loaded = 'true';
|
|
||||||
} else {
|
|
||||||
layoutsContainer.innerHTML = '<div class="no-layouts">No layouts found. Click the refresh icon to scrape layouts.</div>';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading layouts:', error);
|
|
||||||
layoutsContainer.innerHTML = '<div class="no-layouts">Error loading layouts</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrapeCourses() {
|
|
||||||
const btn = document.getElementById('scrape-courses-btn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<i class="fas fa-sync-alt spinning"></i> Scraping...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/scrape-courses', {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
alert(data.message);
|
|
||||||
await loadCourses(); // Reload the courses list
|
|
||||||
searchCourses(); // Reapply search filter if any
|
|
||||||
} else {
|
|
||||||
alert('Failed to scrape courses');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error scraping courses:', error);
|
|
||||||
alert('Error scraping courses');
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="fas fa-sync-alt"></i> Scrape Courses';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrapeLayouts(courseId, courseName) {
|
|
||||||
const icon = document.querySelector(`#row-${courseId} .refresh-icon`);
|
|
||||||
icon.classList.add('spinning');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/scrape-layouts/${courseId}`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.status === 409) {
|
|
||||||
// Scrape already in progress
|
|
||||||
alert(data.message || 'Scrape already in progress for this course. Please wait.');
|
|
||||||
} else if (data.success) {
|
|
||||||
// Reset the loaded state to force reload
|
|
||||||
const layoutsContainer = document.getElementById(`layouts-container-${courseId}`);
|
|
||||||
layoutsContainer.dataset.loaded = 'false';
|
|
||||||
|
|
||||||
// If the row is expanded, reload layouts
|
|
||||||
const layoutsRow = document.getElementById(`layouts-${courseId}`);
|
|
||||||
if (layoutsRow.style.display === 'table-row') {
|
|
||||||
toggleCourseLayouts(courseId);
|
|
||||||
// Re-expand to show new data
|
|
||||||
setTimeout(() => toggleCourseLayouts(courseId), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(data.message);
|
|
||||||
} else {
|
|
||||||
alert('Failed to scrape layouts');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error scraping layouts:', error);
|
|
||||||
alert('Error scraping layouts');
|
|
||||||
} finally {
|
|
||||||
icon.classList.remove('spinning');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load courses on page load
|
|
||||||
loadCourses();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-1478
File diff suppressed because it is too large
Load Diff
Generated
+69
-1
@@ -8,6 +8,7 @@
|
|||||||
"name": "pdga-ratings",
|
"name": "pdga-ratings",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ejs": "^4.0.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"puppeteer": "^21.0.0",
|
"puppeteer": "^21.0.0",
|
||||||
@@ -261,13 +262,18 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/b4a": {
|
"node_modules/b4a": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/bare-events": {
|
"node_modules/bare-events": {
|
||||||
@@ -745,6 +751,21 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ejs": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-krvQtxc0btwSm/nvnt1UpnaFDFVJpJ0fdckmALpCgShsr/iGYHTnJiUliZTgmzq/UxTX33TtOQVKaNigMQp/6Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jake": "^10.9.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ejs": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -1000,6 +1021,36 @@
|
|||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/filelist": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/filelist/node_modules/brace-expansion": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/filelist/node_modules/minimatch": {
|
||||||
|
"version": "5.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1549,6 +1600,23 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/jake": {
|
||||||
|
"version": "10.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
||||||
|
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"async": "^3.2.6",
|
||||||
|
"filelist": "^1.0.4",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"jake": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ejs": "^4.0.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"puppeteer": "^21.0.0",
|
"puppeteer": "^21.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/* Course page styles */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
margin: 0 15px;
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-info {
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layouts-container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-item {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 5px 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-par {
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-layouts {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-layouts-accordion {
|
||||||
|
margin-top: 15px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header {
|
||||||
|
padding: 12px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header:hover {
|
||||||
|
background-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-icon {
|
||||||
|
transition: transform 0.3s;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-icon.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-content.expanded {
|
||||||
|
max-height: 2000px;
|
||||||
|
padding: 10px;
|
||||||
|
transition: max-height 0.5s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-item.inactive {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/* Player ratings page styles */
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 0%;
|
||||||
|
height: 30px;
|
||||||
|
background-color: #007bff;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 30px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mobile-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 250px;
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-chart {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10000;
|
||||||
|
display: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.std-dev-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10000;
|
||||||
|
display: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.predicted-value {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdga-number {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.difference {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-change {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-section {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-icon:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: #007bff !important;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 10001;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-header {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #007bff;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-log {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-line;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 15px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-player-section {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-player-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-player-form {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdga-input {
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
width: 250px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdga-input:focus {
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 10001;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #495057;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 15px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.add-player-section {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.add-player-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.pdga-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/* Shared styles for PDGA Ratings app */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
table {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
.mobile-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-row:hover {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-content {
|
||||||
|
display: none;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-top: 2px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-content td {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background-color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6c757d;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: #007bff;
|
||||||
|
background-color: rgba(0, 123, 255, 0.1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-icon.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
color: #007bff;
|
||||||
|
opacity: 1;
|
||||||
|
background-color: rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
function createRatingChart(container, history) {
|
||||||
|
const width = container.clientWidth - 40;
|
||||||
|
const height = 250;
|
||||||
|
const margin = { top: 20, right: 30, bottom: 40, left: 60 };
|
||||||
|
const chartWidth = width - margin.left - margin.right;
|
||||||
|
const chartHeight = height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
const pdgaNumber = container.id.replace('chart-', '');
|
||||||
|
const tooltip = document.getElementById(`tooltip-${pdgaNumber}`);
|
||||||
|
|
||||||
|
const ratings = history.map(h => h.rating);
|
||||||
|
const minRating = Math.min(...ratings) - 10;
|
||||||
|
const maxRating = Math.max(...ratings) + 10;
|
||||||
|
|
||||||
|
const xStep = chartWidth / (history.length - 1 || 1);
|
||||||
|
const yScale = chartHeight / (maxRating - minRating);
|
||||||
|
|
||||||
|
let svg = `<svg width="${width}" height="${height}" style="font-family: Arial, sans-serif;" id="svg-${pdgaNumber}">`;
|
||||||
|
|
||||||
|
svg += `<rect x="0" y="0" width="${width}" height="${height}" fill="white" stroke="#ddd"/>`;
|
||||||
|
svg += `<rect x="${margin.left}" y="${margin.top}" width="${chartWidth}" height="${chartHeight}" fill="#f9f9f9" stroke="#ccc"/>`;
|
||||||
|
|
||||||
|
for (let i = 0; i <= 5; i++) {
|
||||||
|
const y = margin.top + (i * chartHeight / 5);
|
||||||
|
const rating = Math.round(maxRating - (i * (maxRating - minRating) / 5));
|
||||||
|
svg += `<line x1="${margin.left}" y1="${y}" x2="${margin.left + chartWidth}" y2="${y}" stroke="#e0e0e0"/>`;
|
||||||
|
svg += `<text x="${margin.left - 5}" y="${y + 4}" text-anchor="end" font-size="12" fill="#666">${rating}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathData = '';
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
history.forEach((point, i) => {
|
||||||
|
const x = margin.left + (i * xStep);
|
||||||
|
const y = margin.top + ((maxRating - point.rating) * yScale);
|
||||||
|
|
||||||
|
points.push({ x, y, rating: point.rating, date: point.displayDate });
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
pathData += `M ${x} ${y}`;
|
||||||
|
} else {
|
||||||
|
pathData += ` L ${x} ${y}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg += `<path d="${pathData}" stroke="#007bff" stroke-width="2" fill="none"/>`;
|
||||||
|
|
||||||
|
points.forEach((point, i) => {
|
||||||
|
svg += `<circle cx="${point.x}" cy="${point.y}" r="12" fill="transparent" class="hover-area" data-index="${i}" style="cursor: pointer;"/>`;
|
||||||
|
svg += `<circle cx="${point.x}" cy="${point.y}" r="4" fill="#007bff" stroke="white" stroke-width="2" class="data-point" data-index="${i}" style="pointer-events: none;"/>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelStep = Math.max(1, Math.floor(history.length / 6));
|
||||||
|
history.forEach((point, i) => {
|
||||||
|
if (i % labelStep === 0 || i === history.length - 1) {
|
||||||
|
const x = margin.left + (i * xStep);
|
||||||
|
const date = new Date(point.date).toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||||
|
svg += `<text x="${x}" y="${height - 10}" text-anchor="middle" font-size="11" fill="#666">${date}</text>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg += `<text x="20" y="${height/2}" text-anchor="middle" font-size="12" fill="#333" transform="rotate(-90, 20, ${height/2})">Rating</text>`;
|
||||||
|
|
||||||
|
svg += '</svg>';
|
||||||
|
|
||||||
|
container.innerHTML = svg;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const svgElement = document.getElementById(`svg-${pdgaNumber}`);
|
||||||
|
|
||||||
|
if (svgElement) {
|
||||||
|
const hoverAreas = svgElement.querySelectorAll('.hover-area');
|
||||||
|
const dataPoints = svgElement.querySelectorAll('.data-point');
|
||||||
|
|
||||||
|
let currentTooltip = null;
|
||||||
|
let tooltipTimeout = null;
|
||||||
|
|
||||||
|
hoverAreas.forEach((area, i) => {
|
||||||
|
area.addEventListener('mouseenter', (e) => {
|
||||||
|
if (tooltipTimeout) {
|
||||||
|
clearTimeout(tooltipTimeout);
|
||||||
|
tooltipTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTooltip !== null && currentTooltip !== i) {
|
||||||
|
dataPoints[currentTooltip].setAttribute('r', '4');
|
||||||
|
dataPoints[currentTooltip].setAttribute('fill', '#007bff');
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTooltip = i;
|
||||||
|
const point = points[i];
|
||||||
|
tooltip.innerHTML = `<strong>${point.date}</strong><br>Rating: ${point.rating}`;
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
tooltip.style.left = `${e.clientX + 15}px`;
|
||||||
|
tooltip.style.top = `${e.clientY - 35}px`;
|
||||||
|
|
||||||
|
dataPoints[i].setAttribute('r', '6');
|
||||||
|
dataPoints[i].setAttribute('fill', '#0056b3');
|
||||||
|
});
|
||||||
|
|
||||||
|
area.addEventListener('mousemove', (e) => {
|
||||||
|
if (currentTooltip === i) {
|
||||||
|
tooltip.style.left = `${e.clientX + 15}px`;
|
||||||
|
tooltip.style.top = `${e.clientY - 35}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
area.addEventListener('mouseleave', () => {
|
||||||
|
if (currentTooltip === i) {
|
||||||
|
tooltipTimeout = setTimeout(() => {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
dataPoints[i].setAttribute('r', '4');
|
||||||
|
dataPoints[i].setAttribute('fill', '#007bff');
|
||||||
|
currentTooltip = null;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
let allCourses = [];
|
||||||
|
|
||||||
|
async function loadCourses() {
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const tableDiv = document.getElementById('courses-table');
|
||||||
|
|
||||||
|
loading.style.display = 'block';
|
||||||
|
tableDiv.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/courses');
|
||||||
|
allCourses = await response.json();
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
displayCourses(allCourses);
|
||||||
|
updateSearchInfo(allCourses.length, allCourses.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading courses:', error);
|
||||||
|
loading.style.display = 'none';
|
||||||
|
tableDiv.innerHTML = '<p>Error loading courses. Please try again.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchCourses() {
|
||||||
|
const searchInput = document.getElementById('course-search');
|
||||||
|
const searchTerm = searchInput.value.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!searchTerm) {
|
||||||
|
displayCourses(allCourses);
|
||||||
|
updateSearchInfo(allCourses.length, allCourses.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allCourses.filter(course => {
|
||||||
|
return course.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
course.city.toLowerCase().includes(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
displayCourses(filtered);
|
||||||
|
updateSearchInfo(filtered.length, allCourses.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearchInfo(showing, total) {
|
||||||
|
const infoDiv = document.getElementById('search-results-info');
|
||||||
|
if (showing === total) {
|
||||||
|
infoDiv.textContent = `Showing all ${total} courses`;
|
||||||
|
} else {
|
||||||
|
infoDiv.textContent = `Showing ${showing} of ${total} courses`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayCourses(courses) {
|
||||||
|
const tableDiv = document.getElementById('courses-table');
|
||||||
|
|
||||||
|
if (courses.length === 0) {
|
||||||
|
tableDiv.innerHTML = '<p>No courses found. Click "Scrape Courses" to load Swedish courses from PDGA.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableHTML = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Course Name</th>
|
||||||
|
<th class="mobile-hide">City</th>
|
||||||
|
<th class="mobile-hide">Last Updated</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
courses.forEach(course => {
|
||||||
|
const lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHTML += `
|
||||||
|
<tr id="row-${course.id}" class="expandable-row" onclick="toggleCourseLayouts(${course.id})">
|
||||||
|
<td>
|
||||||
|
<a href="${course.link}" target="_blank" onclick="event.stopPropagation()">${course.name}</a>
|
||||||
|
<div class="mobile-only" style="font-size: 11px; color: #999; margin-top: 2px;">${course.city}</div>
|
||||||
|
</td>
|
||||||
|
<td class="mobile-hide">${course.city}</td>
|
||||||
|
<td class="mobile-hide">${lastUpdated}</td>
|
||||||
|
<td>
|
||||||
|
<i class="fas fa-sync-alt refresh-icon" onclick="scrapeLayouts(${course.id}, '${course.name.replace(/'/g, "\\'")}'); event.stopPropagation();" title="Scrape layouts for this course"></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="layouts-${course.id}" class="expanded-content">
|
||||||
|
<td colspan="4">
|
||||||
|
<div class="layouts-container" id="layouts-container-${course.id}">
|
||||||
|
<div class="no-layouts">Click to load layouts...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHTML += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tableDiv.innerHTML = tableHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAccordion(accordionId) {
|
||||||
|
const content = document.getElementById(accordionId);
|
||||||
|
const icon = document.getElementById(`${accordionId}-icon`);
|
||||||
|
|
||||||
|
if (content.classList.contains('expanded')) {
|
||||||
|
content.classList.remove('expanded');
|
||||||
|
icon.classList.remove('expanded');
|
||||||
|
} else {
|
||||||
|
content.classList.add('expanded');
|
||||||
|
icon.classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCourseLayouts(courseId) {
|
||||||
|
const layoutsRow = document.getElementById(`layouts-${courseId}`);
|
||||||
|
const layoutsContainer = document.getElementById(`layouts-container-${courseId}`);
|
||||||
|
|
||||||
|
if (layoutsRow.style.display === 'table-row') {
|
||||||
|
layoutsRow.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutsRow.style.display = 'table-row';
|
||||||
|
|
||||||
|
if (layoutsContainer.dataset.loaded === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutsContainer.innerHTML = '<div class="no-layouts">Loading layouts...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/layouts/${courseId}`);
|
||||||
|
const layouts = await response.json();
|
||||||
|
|
||||||
|
if (layouts.length > 0) {
|
||||||
|
const oneYearAgo = new Date();
|
||||||
|
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
|
||||||
|
|
||||||
|
const activeLayouts = [];
|
||||||
|
const inactiveLayouts = [];
|
||||||
|
|
||||||
|
layouts.forEach(layout => {
|
||||||
|
if (layout.last_played) {
|
||||||
|
const lastPlayedDate = new Date(layout.last_played);
|
||||||
|
if (lastPlayedDate >= oneYearAgo) {
|
||||||
|
activeLayouts.push(layout);
|
||||||
|
} else {
|
||||||
|
inactiveLayouts.push(layout);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inactiveLayouts.push(layout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let layoutsHTML = '<h4 style="margin-top: 0;">Layouts:</h4>';
|
||||||
|
|
||||||
|
if (activeLayouts.length > 0) {
|
||||||
|
activeLayouts.forEach(layout => {
|
||||||
|
const ratingDisplay = layout.mean_rating ?
|
||||||
|
`<span style="color: #28a745; font-weight: bold; margin-left: 10px;">Rating: ${layout.mean_rating}</span>` :
|
||||||
|
'';
|
||||||
|
const dateDisplay = layout.last_played ?
|
||||||
|
`<span style="color: #6c757d; font-size: 12px; margin-left: 10px;">Last played: ${new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>` :
|
||||||
|
'';
|
||||||
|
layoutsHTML += `
|
||||||
|
<div class="layout-item">
|
||||||
|
<div>
|
||||||
|
<span class="layout-name">${layout.name}</span>
|
||||||
|
${dateDisplay}
|
||||||
|
</div>
|
||||||
|
<span class="layout-par">Par ${layout.par}${ratingDisplay}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inactiveLayouts.length > 0) {
|
||||||
|
const accordionId = `accordion-${courseId}`;
|
||||||
|
layoutsHTML += `
|
||||||
|
<div class="inactive-layouts-accordion">
|
||||||
|
<div class="accordion-header" onclick="toggleAccordion('${accordionId}')">
|
||||||
|
<span class="accordion-header-text">Inactive Layouts (${inactiveLayouts.length}) - Not played in last year</span>
|
||||||
|
<span class="accordion-icon" id="${accordionId}-icon">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="accordion-content" id="${accordionId}">
|
||||||
|
`;
|
||||||
|
|
||||||
|
inactiveLayouts.forEach(layout => {
|
||||||
|
const ratingDisplay = layout.mean_rating ?
|
||||||
|
`<span style="color: #28a745; font-weight: bold; margin-left: 10px;">Rating: ${layout.mean_rating}</span>` :
|
||||||
|
'';
|
||||||
|
const dateDisplay = layout.last_played ?
|
||||||
|
`<span style="color: #6c757d; font-size: 12px; margin-left: 10px;">Last played: ${new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>` :
|
||||||
|
`<span style="color: #dc3545; font-size: 12px; margin-left: 10px;">Never played</span>`;
|
||||||
|
layoutsHTML += `
|
||||||
|
<div class="layout-item inactive">
|
||||||
|
<div>
|
||||||
|
<span class="layout-name">${layout.name}</span>
|
||||||
|
${dateDisplay}
|
||||||
|
</div>
|
||||||
|
<span class="layout-par">Par ${layout.par}${ratingDisplay}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
layoutsHTML += `
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeLayouts.length === 0 && inactiveLayouts.length === 0) {
|
||||||
|
layoutsHTML = '<div class="no-layouts">No layouts found. Click the refresh icon to scrape layouts.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutsContainer.innerHTML = layoutsHTML;
|
||||||
|
layoutsContainer.dataset.loaded = 'true';
|
||||||
|
} else {
|
||||||
|
layoutsContainer.innerHTML = '<div class="no-layouts">No layouts found. Click the refresh icon to scrape layouts.</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading layouts:', error);
|
||||||
|
layoutsContainer.innerHTML = '<div class="no-layouts">Error loading layouts</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeCourses() {
|
||||||
|
const btn = document.getElementById('scrape-courses-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-sync-alt spinning"></i> Scraping...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/scrape-courses', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.message);
|
||||||
|
await loadCourses();
|
||||||
|
searchCourses();
|
||||||
|
} else {
|
||||||
|
alert('Failed to scrape courses');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping courses:', error);
|
||||||
|
alert('Error scraping courses');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-sync-alt"></i> Scrape Courses';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeLayouts(courseId, courseName) {
|
||||||
|
const icon = document.querySelector(`#row-${courseId} .refresh-icon`);
|
||||||
|
icon.classList.add('spinning');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scrape-layouts/${courseId}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
alert(data.message || 'Scrape already in progress for this course. Please wait.');
|
||||||
|
} else if (data.success) {
|
||||||
|
const layoutsContainer = document.getElementById(`layouts-container-${courseId}`);
|
||||||
|
layoutsContainer.dataset.loaded = 'false';
|
||||||
|
|
||||||
|
const layoutsRow = document.getElementById(`layouts-${courseId}`);
|
||||||
|
if (layoutsRow.style.display === 'table-row') {
|
||||||
|
toggleCourseLayouts(courseId);
|
||||||
|
setTimeout(() => toggleCourseLayouts(courseId), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(data.message);
|
||||||
|
} else {
|
||||||
|
alert('Failed to scrape layouts');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping layouts:', error);
|
||||||
|
alert('Error scraping layouts');
|
||||||
|
} finally {
|
||||||
|
icon.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
let cachedDebugInfo = {};
|
||||||
|
let pendingPlayerData = null;
|
||||||
|
|
||||||
|
function displayRatings(ratings) {
|
||||||
|
const tableDiv = document.getElementById('ratings-table');
|
||||||
|
|
||||||
|
if (ratings.length === 0) {
|
||||||
|
tableDiv.innerHTML = '<p>No ratings found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableHTML = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="mobile-hide">Rank</th>
|
||||||
|
<th>Player Name</th>
|
||||||
|
<th class="mobile-hide">PDGA #</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
<th class="mobile-hide">Change</th>
|
||||||
|
<th class="mobile-hide">Predicted</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
ratings.forEach((player, index) => {
|
||||||
|
const difference = player.predictedRating && player.rating ?
|
||||||
|
player.predictedRating - player.rating : 0;
|
||||||
|
const diffText = difference > 0 ? `+${difference}` : difference.toString();
|
||||||
|
const diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral';
|
||||||
|
|
||||||
|
const ratingChangeText = player.ratingChange ?
|
||||||
|
(player.ratingChange > 0 ? `+${player.ratingChange}` : player.ratingChange.toString()) : 'N/A';
|
||||||
|
const ratingChangeClass = player.ratingChange > 0 ? 'positive' :
|
||||||
|
player.ratingChange < 0 ? 'negative' : 'neutral';
|
||||||
|
|
||||||
|
tableHTML += `
|
||||||
|
<tr id="row-${player.pdgaNumber}" class="expandable-row" onclick="togglePlayerHistory(${player.pdgaNumber})">
|
||||||
|
<td class="mobile-hide">${index + 1}</td>
|
||||||
|
<td class="player-name">
|
||||||
|
<a href="https://www.pdga.com/player/${player.pdgaNumber}" target="_blank" onclick="event.stopPropagation()">${player.name}</a>
|
||||||
|
<div class="mobile-only pdga-number" style="font-size: 11px; color: #999; margin-top: 2px;">PDGA #${player.pdgaNumber}</div>
|
||||||
|
</td>
|
||||||
|
<td class="pdga-number mobile-hide">#${player.pdgaNumber}</td>
|
||||||
|
<td class="rating">
|
||||||
|
<div class="refresh-section">
|
||||||
|
<span class="rating-value" data-rating="${player.rating || ''}" data-stddev="${player.stdDev || ''}" data-pdga="${player.pdgaNumber}" style="cursor: help;">${player.rating || '<span style="color: #999; font-style: italic;">Click refresh</span>'}</span>
|
||||||
|
<i class="fas fa-sync-alt refresh-icon" onclick="refreshPlayer(${player.pdgaNumber})" title="Refresh player data"></i>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-only rating-change ${ratingChangeClass}" style="font-size: 11px; margin-top: 2px;">${ratingChangeText}</div>
|
||||||
|
<div class="std-dev-tooltip" id="tooltip-rating-${player.pdgaNumber}"></div>
|
||||||
|
</td>
|
||||||
|
<td class="rating-change ${ratingChangeClass} mobile-hide">${ratingChangeText}</td>
|
||||||
|
<td class="predicted-rating mobile-hide" id="predicted-${player.pdgaNumber}">
|
||||||
|
<div class="refresh-section">
|
||||||
|
<span class="predicted-value" data-stddev="${player.stdDev || ''}" data-pdga="${player.pdgaNumber}" style="cursor: help;">${player.predictedRating || 'N/A'}</span>
|
||||||
|
<i class="fas fa-question-circle debug-icon" onclick="showDebugInfo(${player.pdgaNumber})" title="Show calculation details" style="margin-left: 5px; color: #6c757d; cursor: pointer; opacity: 0.6;"></i>
|
||||||
|
<i class="fas fa-sync-alt refresh-icon" onclick="refreshRoundHistory(${player.pdgaNumber})" title="Refresh prediction data"></i>
|
||||||
|
</div>
|
||||||
|
<div class="std-dev-tooltip" id="tooltip-stddev-${player.pdgaNumber}"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="history-${player.pdgaNumber}" class="expanded-content">
|
||||||
|
<td colspan="6" class="expanded-cell">
|
||||||
|
<div class="chart-title">
|
||||||
|
<div class="refresh-section">
|
||||||
|
Rating History for ${player.name}
|
||||||
|
<i class="fas fa-sync-alt refresh-icon" onclick="refreshRatingHistory(${player.pdgaNumber})" title="Refresh rating history"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container" id="chart-${player.pdgaNumber}" style="position: relative;">
|
||||||
|
<div class="loading-chart">Click to load rating history...</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-tooltip" id="tooltip-${player.pdgaNumber}"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHTML += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tableDiv.innerHTML = tableHTML;
|
||||||
|
|
||||||
|
document.querySelectorAll('.predicted-value').forEach(span => {
|
||||||
|
const pdgaNumber = span.dataset.pdga;
|
||||||
|
const stdDev = span.dataset.stddev;
|
||||||
|
const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`);
|
||||||
|
|
||||||
|
if (stdDev && tooltip) {
|
||||||
|
setupTooltip(span, tooltip, () => `Standard Deviation: \u00b1${stdDev}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.rating-value').forEach(span => {
|
||||||
|
const pdgaNumber = span.dataset.pdga;
|
||||||
|
const rating = parseInt(span.dataset.rating);
|
||||||
|
const stdDev = parseInt(span.dataset.stddev);
|
||||||
|
const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`);
|
||||||
|
|
||||||
|
if (rating && stdDev && tooltip) {
|
||||||
|
const minRating = rating - stdDev;
|
||||||
|
const maxRating = rating + stdDev;
|
||||||
|
setupTooltip(span, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlayerHistory(pdgaNumber) {
|
||||||
|
const historyRow = document.getElementById(`history-${pdgaNumber}`);
|
||||||
|
const chartContainer = document.getElementById(`chart-${pdgaNumber}`);
|
||||||
|
|
||||||
|
if (historyRow.style.display === 'table-row') {
|
||||||
|
historyRow.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyRow.style.display = 'table-row';
|
||||||
|
|
||||||
|
if (chartContainer.dataset.loaded === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chartContainer.innerHTML = '<div class="loading-chart">Loading rating history...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/rating-history/${pdgaNumber}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.history && data.history.length > 0) {
|
||||||
|
createRatingChart(chartContainer, data.history);
|
||||||
|
chartContainer.dataset.loaded = 'true';
|
||||||
|
} else {
|
||||||
|
chartContainer.innerHTML = '<div class="loading-chart">No rating history available</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading rating history:', error);
|
||||||
|
chartContainer.innerHTML = '<div class="loading-chart">Error loading rating history</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCache() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/clear-cache', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.message);
|
||||||
|
if (confirm('Reload page to fetch fresh data?')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('Failed to clear cache');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing cache:', error);
|
||||||
|
alert('Error clearing cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPlayer(pdgaNumber) {
|
||||||
|
const icon = document.querySelector(`#row-${pdgaNumber} .rating .refresh-icon`);
|
||||||
|
icon.classList.add('spinning');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/refresh-player/${pdgaNumber}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const row = document.getElementById(`row-${pdgaNumber}`);
|
||||||
|
const ratingCell = row.querySelector('.rating');
|
||||||
|
const ratingChangeCell = row.querySelector('.rating-change');
|
||||||
|
|
||||||
|
const nameLink = row.querySelector('.player-name a');
|
||||||
|
nameLink.textContent = data.player.name;
|
||||||
|
|
||||||
|
const ratingChangeText = data.player.ratingChange ?
|
||||||
|
(data.player.ratingChange > 0 ? `+${data.player.ratingChange}` : data.player.ratingChange.toString()) : 'N/A';
|
||||||
|
const ratingChangeClass = data.player.ratingChange > 0 ? 'positive' :
|
||||||
|
data.player.ratingChange < 0 ? 'negative' : 'neutral';
|
||||||
|
|
||||||
|
const ratingValue = ratingCell.querySelector('.rating-value');
|
||||||
|
if (ratingValue) {
|
||||||
|
ratingValue.textContent = data.player.rating || 'N/A';
|
||||||
|
ratingValue.dataset.rating = data.player.rating || '';
|
||||||
|
|
||||||
|
const stdDev = parseInt(ratingValue.dataset.stddev);
|
||||||
|
const rating = parseInt(data.player.rating);
|
||||||
|
const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`);
|
||||||
|
|
||||||
|
if (rating && stdDev && tooltip) {
|
||||||
|
const minRating = rating - stdDev;
|
||||||
|
const maxRating = rating + stdDev;
|
||||||
|
replaceWithTooltip(ratingValue, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingChangeCell) ratingChangeCell.textContent = ratingChangeText;
|
||||||
|
if (ratingChangeCell) ratingChangeCell.className = `rating-change ${ratingChangeClass} mobile-hide`;
|
||||||
|
|
||||||
|
const mobileChange = ratingCell.querySelector('.mobile-only.rating-change');
|
||||||
|
if (mobileChange) {
|
||||||
|
mobileChange.textContent = ratingChangeText;
|
||||||
|
mobileChange.className = `mobile-only rating-change ${ratingChangeClass}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing player:', error);
|
||||||
|
alert('Failed to refresh player data');
|
||||||
|
} finally {
|
||||||
|
icon.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRoundHistory(pdgaNumber) {
|
||||||
|
const icon = document.querySelector(`#predicted-${pdgaNumber} .refresh-icon`);
|
||||||
|
icon.classList.add('spinning');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
if (data.debugLog) {
|
||||||
|
cachedDebugInfo[pdgaNumber] = data.debugLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
const predictedCell = document.getElementById(`predicted-${pdgaNumber}`);
|
||||||
|
if (predictedCell) {
|
||||||
|
const predictedValue = predictedCell.querySelector('.predicted-value');
|
||||||
|
if (predictedValue) {
|
||||||
|
predictedValue.textContent = data.predictedRating || 'N/A';
|
||||||
|
predictedValue.dataset.stddev = data.stdDev || '';
|
||||||
|
|
||||||
|
const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`);
|
||||||
|
if (data.stdDev && tooltip) {
|
||||||
|
replaceWithTooltip(predictedValue, tooltip, () => `Standard Deviation: \u00b1${data.stdDev}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.getElementById(`row-${pdgaNumber}`);
|
||||||
|
const ratingCell = row.querySelector('.rating');
|
||||||
|
const ratingValue = ratingCell.querySelector('.rating-value');
|
||||||
|
if (ratingValue && data.stdDev) {
|
||||||
|
ratingValue.dataset.stddev = data.stdDev;
|
||||||
|
|
||||||
|
const rating = parseInt(ratingValue.dataset.rating);
|
||||||
|
const stdDev = parseInt(data.stdDev);
|
||||||
|
const ratingTooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`);
|
||||||
|
|
||||||
|
if (rating && stdDev && ratingTooltip) {
|
||||||
|
const minRating = rating - stdDev;
|
||||||
|
const maxRating = rating + stdDev;
|
||||||
|
replaceWithTooltip(ratingValue, ratingTooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffCell = document.getElementById(`diff-${pdgaNumber}`);
|
||||||
|
if (diffCell) {
|
||||||
|
const currentRatingElement = document.querySelector(`#row-${pdgaNumber} .rating .refresh-section`);
|
||||||
|
if (currentRatingElement && currentRatingElement.firstChild) {
|
||||||
|
const currentRatingText = currentRatingElement.firstChild.textContent;
|
||||||
|
const currentRating = parseInt(currentRatingText);
|
||||||
|
|
||||||
|
if (data.predictedRating && currentRating && !isNaN(currentRating)) {
|
||||||
|
const difference = data.predictedRating - currentRating;
|
||||||
|
const diffText = difference > 0 ? `+${difference}` : difference.toString();
|
||||||
|
const diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral';
|
||||||
|
|
||||||
|
diffCell.className = `difference ${diffClass}`;
|
||||||
|
diffCell.textContent = diffText;
|
||||||
|
} else {
|
||||||
|
diffCell.innerHTML = '<span style="color: #999; font-style: italic;">Use refresh</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing round history:', error);
|
||||||
|
|
||||||
|
let errorMessage = 'Failed to refresh prediction data';
|
||||||
|
let errorDetails = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = JSON.parse(error.message);
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
|
||||||
|
if (errorData.message) {
|
||||||
|
errorDetails = errorData.message;
|
||||||
|
} else {
|
||||||
|
errorDetails = errorData.details || '';
|
||||||
|
if (errorData.suggestion) {
|
||||||
|
errorDetails += '\n\nSuggestion: ' + errorData.suggestion;
|
||||||
|
}
|
||||||
|
if (errorData.errorType) {
|
||||||
|
errorDetails += '\n\nError Type: ' + errorData.errorType;
|
||||||
|
}
|
||||||
|
if (errorData.timestamp) {
|
||||||
|
errorDetails += '\n\nTime: ' + new Date(errorData.timestamp).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
errorDetails = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMessage = errorDetails ? errorMessage + '\n\n' + errorDetails : errorMessage;
|
||||||
|
alert(fullMessage);
|
||||||
|
} finally {
|
||||||
|
icon.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRatingHistory(pdgaNumber) {
|
||||||
|
const icon = document.querySelector(`#history-${pdgaNumber} .chart-title .refresh-icon`);
|
||||||
|
icon.classList.add('spinning');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/refresh-rating-history/${pdgaNumber}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const chartContainer = document.getElementById(`chart-${pdgaNumber}`);
|
||||||
|
chartContainer.dataset.loaded = 'false';
|
||||||
|
|
||||||
|
if (data.history && data.history.length > 0) {
|
||||||
|
createRatingChart(chartContainer, data.history);
|
||||||
|
chartContainer.dataset.loaded = 'true';
|
||||||
|
} else {
|
||||||
|
chartContainer.innerHTML = '<div class="loading-chart">No rating history available</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing rating history:', error);
|
||||||
|
alert('Failed to refresh rating history');
|
||||||
|
} finally {
|
||||||
|
icon.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAllPlayers() {
|
||||||
|
const icons = document.querySelectorAll('th .refresh-icon');
|
||||||
|
const ratingIcon = icons[0];
|
||||||
|
ratingIcon.classList.add('spinning');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ratings = await getAllPlayersFromDB();
|
||||||
|
displayRatings(ratings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing all players:', error);
|
||||||
|
alert('Failed to refresh player data');
|
||||||
|
} finally {
|
||||||
|
ratingIcon.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAllPredictions() {
|
||||||
|
const icons = document.querySelectorAll('th .refresh-icon');
|
||||||
|
const predictedIcon = icons[1];
|
||||||
|
predictedIcon.classList.add('spinning');
|
||||||
|
|
||||||
|
try {
|
||||||
|
alert('Bulk prediction refresh not implemented yet. Use individual refresh icons.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing all predictions:', error);
|
||||||
|
alert('Failed to refresh predictions');
|
||||||
|
} finally {
|
||||||
|
predictedIcon.classList.remove('spinning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDebugInfo(pdgaNumber) {
|
||||||
|
const modal = document.getElementById('debug-modal');
|
||||||
|
const header = document.getElementById('debug-header');
|
||||||
|
const log = document.getElementById('debug-log');
|
||||||
|
|
||||||
|
const playerNameElement = document.querySelector(`#row-${pdgaNumber} .player-name a`);
|
||||||
|
const playerName = playerNameElement ? playerNameElement.textContent : `PDGA #${pdgaNumber}`;
|
||||||
|
|
||||||
|
header.textContent = `Prediction Calculation Details - ${playerName}`;
|
||||||
|
log.textContent = 'Loading calculation details...';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cachedDebugInfo[pdgaNumber]) {
|
||||||
|
log.textContent = cachedDebugInfo[pdgaNumber].join('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.debugLog) {
|
||||||
|
cachedDebugInfo[pdgaNumber] = data.debugLog;
|
||||||
|
log.textContent = data.debugLog.join('\n');
|
||||||
|
} else {
|
||||||
|
log.textContent = 'No debug information available. Try refreshing the prediction first.';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching debug info:', error);
|
||||||
|
log.textContent = 'Error loading debug information. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDebugModal(event) {
|
||||||
|
const modal = document.getElementById('debug-modal');
|
||||||
|
modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchAndAddPlayer() {
|
||||||
|
const input = document.getElementById('pdga-number-input');
|
||||||
|
const pdgaNumber = input.value.trim();
|
||||||
|
|
||||||
|
if (!pdgaNumber) {
|
||||||
|
alert('Please enter a PDGA number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const button = document.querySelector('.btn-add');
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.disabled = true;
|
||||||
|
button.innerHTML = '<i class="fas fa-sync-alt spinning"></i> Searching...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/search-player/${pdgaNumber}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
showErrorModal(data.error || 'Player not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.alreadyExists) {
|
||||||
|
showInfoModal(`${data.player.name} is already being tracked!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingPlayerData = data.player;
|
||||||
|
showConfirmationModal(data.player);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching for player:', error);
|
||||||
|
showErrorModal('Failed to search for player. Please try again.');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfirmationModal(player) {
|
||||||
|
const modal = document.getElementById('add-player-modal');
|
||||||
|
const header = document.getElementById('add-player-modal-header');
|
||||||
|
const body = document.getElementById('add-player-modal-body');
|
||||||
|
const footer = document.getElementById('add-player-modal-footer');
|
||||||
|
|
||||||
|
header.textContent = 'Confirm Player';
|
||||||
|
body.innerHTML = `
|
||||||
|
<p>Is this the correct player you want to add?</p>
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin-top: 15px;">
|
||||||
|
<strong style="font-size: 18px; color: #007bff;">${player.name}</strong><br>
|
||||||
|
<span style="color: #6c757d;">PDGA #${player.pdgaNumber}</span><br>
|
||||||
|
${player.rating ? `<span style="color: #28a745; font-weight: bold;">Current Rating: ${player.rating}</span>` : '<span style="color: #999;">No rating available</span>'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
footer.innerHTML = `
|
||||||
|
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Cancel</button>
|
||||||
|
<button class="btn btn-confirm" onclick="confirmAddPlayer()">Add Player</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorModal(message) {
|
||||||
|
const modal = document.getElementById('add-player-modal');
|
||||||
|
const header = document.getElementById('add-player-modal-header');
|
||||||
|
const body = document.getElementById('add-player-modal-body');
|
||||||
|
const footer = document.getElementById('add-player-modal-footer');
|
||||||
|
|
||||||
|
header.textContent = 'Player Not Found';
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="color: #dc3545;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> ${message}
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 10px; color: #6c757d; font-size: 14px;">
|
||||||
|
Please check the PDGA number and try again.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
footer.innerHTML = `
|
||||||
|
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Close</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInfoModal(message) {
|
||||||
|
const modal = document.getElementById('add-player-modal');
|
||||||
|
const header = document.getElementById('add-player-modal-header');
|
||||||
|
const body = document.getElementById('add-player-modal-body');
|
||||||
|
const footer = document.getElementById('add-player-modal-footer');
|
||||||
|
|
||||||
|
header.textContent = 'Information';
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="color: #007bff;">
|
||||||
|
<i class="fas fa-info-circle"></i> ${message}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
footer.innerHTML = `
|
||||||
|
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Close</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAddPlayer() {
|
||||||
|
if (!pendingPlayerData) {
|
||||||
|
closeAddPlayerModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('add-player-modal');
|
||||||
|
const body = document.getElementById('add-player-modal-body');
|
||||||
|
const footer = document.getElementById('add-player-modal-footer');
|
||||||
|
|
||||||
|
body.innerHTML = '<p style="text-align: center;"><i class="fas fa-sync-alt spinning"></i> Adding player...</p>';
|
||||||
|
footer.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/add-player', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ pdgaNumber: pendingPlayerData.pdgaNumber })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to add player');
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="color: #28a745; text-align: center;">
|
||||||
|
<i class="fas fa-check-circle" style="font-size: 48px; margin-bottom: 15px;"></i><br>
|
||||||
|
<strong>${data.player.name}</strong> has been added successfully!
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
footer.innerHTML = `
|
||||||
|
<button class="btn btn-confirm" onclick="closeAddPlayerModal(); location.reload();">OK</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('pdga-number-input').value = '';
|
||||||
|
pendingPlayerData = null;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding player:', error);
|
||||||
|
body.innerHTML = `
|
||||||
|
<p style="color: #dc3545;">
|
||||||
|
<i class="fas fa-exclamation-circle"></i> ${error.message}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
footer.innerHTML = `
|
||||||
|
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Close</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddPlayerModal(event) {
|
||||||
|
const modal = document.getElementById('add-player-modal');
|
||||||
|
modal.style.display = 'none';
|
||||||
|
pendingPlayerData = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
function fetchRatingsWithProgress() {
|
||||||
|
const progressSection = document.getElementById('progress-section');
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const tableDiv = document.getElementById('ratings-table');
|
||||||
|
|
||||||
|
progressSection.style.display = 'block';
|
||||||
|
tableDiv.innerHTML = '';
|
||||||
|
|
||||||
|
const eventSource = new EventSource('/api/ratings/progress');
|
||||||
|
|
||||||
|
eventSource.onmessage = function(event) {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === 'loading') {
|
||||||
|
const percentage = Math.round((data.current / data.total) * 100);
|
||||||
|
progressBar.style.width = `${percentage}%`;
|
||||||
|
progressBar.textContent = `${percentage}%`;
|
||||||
|
progressText.textContent = `Loading player ${data.current}/${data.total}: PDGA #${data.pdgaNumber}`;
|
||||||
|
} else if (data.status === 'completed') {
|
||||||
|
const percentage = Math.round((data.current / data.total) * 100);
|
||||||
|
progressBar.style.width = `${percentage}%`;
|
||||||
|
progressBar.textContent = `${percentage}%`;
|
||||||
|
progressText.textContent = `Loaded ${data.name} (${data.current}/${data.total})`;
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
const percentage = Math.round((data.current / data.total) * 100);
|
||||||
|
progressBar.style.width = `${percentage}%`;
|
||||||
|
progressBar.textContent = `${percentage}%`;
|
||||||
|
progressText.textContent = `Error loading PDGA #${data.pdgaNumber} (${data.current}/${data.total})`;
|
||||||
|
} else if (data.status === 'complete') {
|
||||||
|
progressSection.style.display = 'none';
|
||||||
|
displayRatings(data.ratings);
|
||||||
|
eventSource.close();
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
progressSection.style.display = 'none';
|
||||||
|
tableDiv.innerHTML = '<p>Error loading ratings. Please try again.</p>';
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
progressSection.style.display = 'none';
|
||||||
|
tableDiv.innerHTML = '<p>Connection error. Please refresh the page.</p>';
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAllPlayers() {
|
||||||
|
const button = document.getElementById('load-all-btn');
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = 'Loading...';
|
||||||
|
button.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const progressSection = document.getElementById('progress-section');
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const tableDiv = document.getElementById('ratings-table');
|
||||||
|
|
||||||
|
progressSection.style.display = 'block';
|
||||||
|
tableDiv.innerHTML = '';
|
||||||
|
|
||||||
|
const eventSource = new EventSource('/api/load-all-players');
|
||||||
|
|
||||||
|
eventSource.onmessage = function(event) {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === 'loading' || data.status === 'completed' || data.status === 'error') {
|
||||||
|
const percentage = Math.round((data.current / data.total) * 100);
|
||||||
|
progressBar.style.width = `${percentage}%`;
|
||||||
|
progressBar.textContent = `${percentage}%`;
|
||||||
|
|
||||||
|
if (data.status === 'loading') {
|
||||||
|
progressText.textContent = `Loading player ${data.current}/${data.total}: PDGA #${data.pdgaNumber}`;
|
||||||
|
} else if (data.status === 'completed') {
|
||||||
|
progressText.textContent = `Loaded ${data.name} (${data.current}/${data.total})`;
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
progressText.textContent = `Error loading PDGA #${data.pdgaNumber} (${data.current}/${data.total})`;
|
||||||
|
}
|
||||||
|
} else if (data.status === 'complete') {
|
||||||
|
progressSection.style.display = 'none';
|
||||||
|
displayRatings(data.ratings);
|
||||||
|
eventSource.close();
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.style.pointerEvents = 'auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
progressSection.style.display = 'none';
|
||||||
|
tableDiv.innerHTML = '<p>Connection error. Please refresh the page.</p>';
|
||||||
|
eventSource.close();
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.style.pointerEvents = 'auto';
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading all players:', error);
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.style.pointerEvents = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
function setupTooltip(element, tooltip, getText) {
|
||||||
|
element.addEventListener('mouseenter', (e) => {
|
||||||
|
tooltip.textContent = getText();
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
tooltip.style.left = `${e.clientX + 15}px`;
|
||||||
|
tooltip.style.top = `${e.clientY - 35}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('mousemove', (e) => {
|
||||||
|
tooltip.style.left = `${e.clientX + 15}px`;
|
||||||
|
tooltip.style.top = `${e.clientY - 35}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('mouseleave', () => {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceWithTooltip(element, tooltip, getText) {
|
||||||
|
const newElement = element.cloneNode(true);
|
||||||
|
element.parentNode.replaceChild(newElement, element);
|
||||||
|
setupTooltip(newElement, tooltip, getText);
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ const pageRoutes = require('./src/routes/pages');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
|
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
app.set('views', path.join(__dirname, 'views/pages'));
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -1,13 +1,17 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../../index.html'));
|
res.render('index');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/courses', (req, res) => {
|
||||||
|
res.render('courses');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep old URL working
|
||||||
router.get('/courses.html', (req, res) => {
|
router.get('/courses.html', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../../courses.html'));
|
res.redirect('/courses');
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<% var body = `
|
||||||
|
<div class="search-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
id="course-search"
|
||||||
|
placeholder="Search courses by name or city..."
|
||||||
|
oninput="searchCourses()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="search-results-info" class="search-results-info"></div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn" onclick="scrapeCourses()" id="scrape-courses-btn">
|
||||||
|
<i class="fas fa-sync-alt"></i> Scrape Courses
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading" class="loading" style="display: none;">Loading courses...</div>
|
||||||
|
<div id="courses-table"></div>
|
||||||
|
`; %>
|
||||||
|
|
||||||
|
<%- include('../partials/layout', {
|
||||||
|
title: 'PDGA Courses - Sweden',
|
||||||
|
heading: 'PDGA Courses - Sweden',
|
||||||
|
activePage: 'courses',
|
||||||
|
cssFiles: ['courses.css'],
|
||||||
|
jsFiles: ['courses.js'],
|
||||||
|
initScript: 'loadCourses();',
|
||||||
|
body: body
|
||||||
|
}) %>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<% var body = `
|
||||||
|
<!-- Add Player Section -->
|
||||||
|
<div class="add-player-section">
|
||||||
|
<h3>Add Yourself to Tracked Players</h3>
|
||||||
|
<div class="add-player-form">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="pdga-number-input"
|
||||||
|
class="pdga-input"
|
||||||
|
placeholder="Enter your PDGA number"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-add" onclick="searchAndAddPlayer()">
|
||||||
|
<i class="fas fa-user-plus"></i> Add Player
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute; top: 10px; right: 15px;">
|
||||||
|
<a href="#" onclick="loadAllPlayers(); return false;" style="color: #007bff; font-size: 11px; text-decoration: none; margin-right: 10px;" title="Load all player data" id="load-all-btn">Load All</a>
|
||||||
|
<a href="#" onclick="clearCache(); return false;" style="color: #ccc; font-size: 12px; text-decoration: none; opacity: 0.3;" title="Clear cache"><i class="fas fa-cog"></i></a>
|
||||||
|
</div>
|
||||||
|
<div id="loading" class="loading" style="display: none;">Loading ratings...</div>
|
||||||
|
<div id="progress-section" style="display: none;">
|
||||||
|
<div class="progress-container">
|
||||||
|
<div id="progress-bar" class="progress-bar">0%</div>
|
||||||
|
</div>
|
||||||
|
<div id="progress-text" class="progress-text">Preparing to load ratings...</div>
|
||||||
|
</div>
|
||||||
|
<div id="ratings-table"></div>
|
||||||
|
`; %>
|
||||||
|
|
||||||
|
<% var modals = `
|
||||||
|
<!-- Debug Modal -->
|
||||||
|
<div id="debug-modal" class="debug-modal" onclick="closeDebugModal(event)">
|
||||||
|
<div class="debug-content" onclick="event.stopPropagation()">
|
||||||
|
<button class="debug-close" onclick="closeDebugModal()">×</button>
|
||||||
|
<div class="debug-header" id="debug-header">Prediction Calculation Details</div>
|
||||||
|
<div class="debug-log" id="debug-log">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Player Confirmation Modal -->
|
||||||
|
<div id="add-player-modal" class="modal" onclick="closeAddPlayerModal(event)">
|
||||||
|
<div class="modal-content" onclick="event.stopPropagation()">
|
||||||
|
<button class="modal-close" onclick="closeAddPlayerModal()">×</button>
|
||||||
|
<div class="modal-header" id="add-player-modal-header">Confirm Player</div>
|
||||||
|
<div class="modal-body" id="add-player-modal-body">Loading...</div>
|
||||||
|
<div class="modal-footer" id="add-player-modal-footer">
|
||||||
|
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Cancel</button>
|
||||||
|
<button class="btn btn-confirm" id="confirm-add-btn" onclick="confirmAddPlayer()">Add Player</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`; %>
|
||||||
|
|
||||||
|
<%- include('../partials/layout', {
|
||||||
|
title: 'PDGA Ratings',
|
||||||
|
heading: 'PDGA Player Ratings',
|
||||||
|
activePage: 'players',
|
||||||
|
cssFiles: ['players.css'],
|
||||||
|
jsFiles: ['tooltips.js', 'chart.js', 'progress.js', 'players.js'],
|
||||||
|
initScript: 'fetchRatingsWithProgress();',
|
||||||
|
body: body,
|
||||||
|
modals: modals
|
||||||
|
}) %>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><%= title %></title>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/shared.css">
|
||||||
|
<% if (typeof cssFiles !== 'undefined') { %>
|
||||||
|
<% cssFiles.forEach(function(file) { %>
|
||||||
|
<link rel="stylesheet" href="/css/<%= file %>">
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1><%= heading %></h1>
|
||||||
|
<%- include('../partials/nav') %>
|
||||||
|
<%- body %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (typeof modals !== 'undefined') { %>
|
||||||
|
<%- modals %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof jsFiles !== 'undefined') { %>
|
||||||
|
<% jsFiles.forEach(function(file) { %>
|
||||||
|
<script src="/js/<%= file %>"></script>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof initScript !== 'undefined') { %>
|
||||||
|
<script>
|
||||||
|
<%- initScript %>
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div style="text-align: center; margin-bottom: 20px;">
|
||||||
|
<a href="/" style="margin: 0 15px; color: <%= activePage === 'players' ? '#333' : '#007bff' %>; text-decoration: none; font-weight: bold;">Player Ratings</a>
|
||||||
|
<a href="/courses" style="margin: 0 15px; color: <%= activePage === 'courses' ? '#333' : '#007bff' %>; text-decoration: none; font-weight: bold;">Courses</a>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user