Merge pull request #2 from shcizo/refactor/modularize-htmx
Refactor: modularize server.js, add EJS/HTMX, stealth scraping
This commit is contained in:
+2
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.cache/
|
||||
*.log
|
||||
*.log
|
||||
*.db
|
||||
-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
+558
-12
@@ -8,9 +8,12 @@
|
||||
"name": "pdga-ratings",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ejs": "^4.0.1",
|
||||
"express": "^4.18.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"puppeteer": "^21.0.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -121,6 +124,21 @@
|
||||
"version": "0.23.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.2.1",
|
||||
"license": "MIT",
|
||||
@@ -247,6 +265,15 @@
|
||||
"version": "2.0.1",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/arr-union": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
|
||||
"integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT"
|
||||
@@ -261,13 +288,18 @@
|
||||
"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": {
|
||||
"version": "1.6.7",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
@@ -355,7 +387,6 @@
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -549,6 +580,22 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-deep": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz",
|
||||
"integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"for-own": "^0.1.3",
|
||||
"is-plain-object": "^2.0.1",
|
||||
"kind-of": "^3.0.2",
|
||||
"lazy-cache": "^1.0.3",
|
||||
"shallow-clone": "^0.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
@@ -575,7 +622,6 @@
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
@@ -682,6 +728,15 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/degenerator": {
|
||||
"version": "5.0.1",
|
||||
"license": "MIT",
|
||||
@@ -745,6 +800,21 @@
|
||||
"version": "1.1.1",
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"license": "MIT"
|
||||
@@ -1000,6 +1070,36 @@
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"dev": true,
|
||||
@@ -1027,6 +1127,27 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/for-in": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
||||
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/for-own": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz",
|
||||
"integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"for-in": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
@@ -1051,6 +1172,20 @@
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
@@ -1067,8 +1202,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
@@ -1206,7 +1340,6 @@
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
@@ -1247,8 +1380,7 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
@@ -1456,7 +1588,6 @@
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
@@ -1501,6 +1632,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"dev": true,
|
||||
@@ -1542,6 +1688,18 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -1549,6 +1707,32 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/isobject": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT"
|
||||
@@ -1567,6 +1751,39 @@
|
||||
"version": "2.3.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
|
||||
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-buffer": "^1.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lazy-cache": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
|
||||
"integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"license": "MIT"
|
||||
@@ -1715,6 +1932,20 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-deep": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz",
|
||||
"integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"arr-union": "^3.1.0",
|
||||
"clone-deep": "^0.2.4",
|
||||
"kind-of": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
@@ -1770,7 +2001,6 @@
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -1887,6 +2117,28 @@
|
||||
"version": "3.0.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mixin-object": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz",
|
||||
"integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"for-in": "^0.1.3",
|
||||
"is-extendable": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mixin-object/node_modules/for-in": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz",
|
||||
"integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
@@ -2205,7 +2457,6 @@
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2435,6 +2686,257 @@
|
||||
"version": "2.1.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/puppeteer-extra": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz",
|
||||
"integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.1.0",
|
||||
"debug": "^4.1.1",
|
||||
"deepmerge": "^4.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/puppeteer": "*",
|
||||
"puppeteer": "*",
|
||||
"puppeteer-core": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/puppeteer": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer-core": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz",
|
||||
"integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.1.0",
|
||||
"debug": "^4.1.1",
|
||||
"merge-deep": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=9.11.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-extra": "*",
|
||||
"puppeteer-extra": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-extra": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer-extra": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-stealth": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz",
|
||||
"integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"puppeteer-extra-plugin": "^3.2.3",
|
||||
"puppeteer-extra-plugin-user-preferences": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-extra": "*",
|
||||
"puppeteer-extra": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-extra": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer-extra": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-stealth/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-stealth/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-user-data-dir": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz",
|
||||
"integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"fs-extra": "^10.0.0",
|
||||
"puppeteer-extra-plugin": "^3.2.3",
|
||||
"rimraf": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-extra": "*",
|
||||
"puppeteer-extra": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-extra": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer-extra": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-user-preferences": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz",
|
||||
"integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"deepmerge": "^4.2.2",
|
||||
"puppeteer-extra-plugin": "^3.2.3",
|
||||
"puppeteer-extra-plugin-user-data-dir": "^2.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-extra": "*",
|
||||
"puppeteer-extra": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright-extra": {
|
||||
"optional": true
|
||||
},
|
||||
"puppeteer-extra": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-user-preferences/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin-user-preferences/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra-plugin/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/puppeteer-extra/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-extra/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -2538,7 +3040,6 @@
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -2638,6 +3139,42 @@
|
||||
"version": "1.2.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shallow-clone": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz",
|
||||
"integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.1",
|
||||
"kind-of": "^2.0.1",
|
||||
"lazy-cache": "^0.2.3",
|
||||
"mixin-object": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shallow-clone/node_modules/kind-of": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz",
|
||||
"integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-buffer": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shallow-clone/node_modules/lazy-cache": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz",
|
||||
"integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
@@ -3080,6 +3617,15 @@
|
||||
"imurmurhash": "^0.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ejs": "^4.0.1",
|
||||
"express": "^4.18.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"puppeteer": "^21.0.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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,87 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
htmx.ajax('GET', `/partials/course-layouts/${courseId}`, {target: `#layouts-container-${courseId}`, swap: 'innerHTML'});
|
||||
layoutsContainer.dataset.loaded = 'true';
|
||||
}
|
||||
|
||||
async function scrapeCourses() {
|
||||
const btn = document.getElementById('scrape-courses-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Scraping...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scrape-courses', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
htmx.ajax('GET', '/partials/course-table', '#courses-table');
|
||||
} else {
|
||||
alert('Failed to scrape courses');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scraping courses:', error);
|
||||
alert('Error scraping courses');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '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') {
|
||||
htmx.ajax('GET', `/partials/course-layouts/${courseId}`, {target: `#layouts-container-${courseId}`, swap: 'innerHTML'});
|
||||
layoutsContainer.dataset.loaded = 'true';
|
||||
}
|
||||
|
||||
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,488 @@
|
||||
let cachedDebugInfo = {};
|
||||
let pendingPlayerData = null;
|
||||
|
||||
function setupTooltipsAfterSwap() {
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'ratings-table') {
|
||||
initRatingsTooltips();
|
||||
}
|
||||
// After player history partial loads, render the chart
|
||||
const target = event.detail.target;
|
||||
if (target.id && target.id.startsWith('history-content-')) {
|
||||
const container = target.querySelector('.chart-container');
|
||||
if (container && container.dataset.history) {
|
||||
try {
|
||||
const history = JSON.parse(container.dataset.history);
|
||||
createRatingChart(container, history);
|
||||
} catch (e) {
|
||||
console.error('Error rendering chart:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initRatingsTooltips() {
|
||||
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})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function togglePlayerHistory(pdgaNumber) {
|
||||
const historyRow = document.getElementById(`history-${pdgaNumber}`);
|
||||
const contentDiv = document.getElementById(`history-content-${pdgaNumber}`);
|
||||
|
||||
if (historyRow.style.display === 'table-row') {
|
||||
historyRow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
historyRow.style.display = 'table-row';
|
||||
|
||||
if (contentDiv.dataset.loaded === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
htmx.ajax('GET', `/partials/player-history/${pdgaNumber}`, {target: `#history-content-${pdgaNumber}`, swap: 'innerHTML'});
|
||||
contentDiv.dataset.loaded = 'true';
|
||||
}
|
||||
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 contentDiv = document.getElementById(`history-content-${pdgaNumber}`);
|
||||
contentDiv.dataset.loaded = 'false';
|
||||
htmx.ajax('GET', `/partials/player-history/${pdgaNumber}`, {target: `#history-content-${pdgaNumber}`, swap: 'innerHTML'});
|
||||
contentDiv.dataset.loaded = 'true';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing rating history:', error);
|
||||
alert('Failed to refresh rating history');
|
||||
} finally {
|
||||
icon.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) {
|
||||
document.getElementById('debug-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 originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = '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.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirmationModal(player) {
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
document.getElementById('add-player-modal-header').textContent = 'Confirm Player';
|
||||
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
body.textContent = '';
|
||||
|
||||
const question = document.createElement('p');
|
||||
question.textContent = 'Is this the correct player you want to add?';
|
||||
body.appendChild(question);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.style.cssText = 'background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin-top: 15px;';
|
||||
|
||||
const name = document.createElement('strong');
|
||||
name.style.cssText = 'font-size: 18px; color: #007bff;';
|
||||
name.textContent = player.name;
|
||||
info.appendChild(name);
|
||||
info.appendChild(document.createElement('br'));
|
||||
|
||||
const pdga = document.createElement('span');
|
||||
pdga.style.color = '#6c757d';
|
||||
pdga.textContent = `PDGA #${player.pdgaNumber}`;
|
||||
info.appendChild(pdga);
|
||||
info.appendChild(document.createElement('br'));
|
||||
|
||||
const rating = document.createElement('span');
|
||||
if (player.rating) {
|
||||
rating.style.cssText = 'color: #28a745; font-weight: bold;';
|
||||
rating.textContent = `Current Rating: ${player.rating}`;
|
||||
} else {
|
||||
rating.style.color = '#999';
|
||||
rating.textContent = 'No rating available';
|
||||
}
|
||||
info.appendChild(rating);
|
||||
body.appendChild(info);
|
||||
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
footer.textContent = '';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn btn-cancel';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
cancelBtn.onclick = closeAddPlayerModal;
|
||||
footer.appendChild(cancelBtn);
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'btn btn-confirm';
|
||||
confirmBtn.textContent = 'Add Player';
|
||||
confirmBtn.onclick = confirmAddPlayer;
|
||||
footer.appendChild(confirmBtn);
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function showErrorModal(message) {
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
document.getElementById('add-player-modal-header').textContent = 'Player Not Found';
|
||||
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
body.textContent = '';
|
||||
|
||||
const errorP = document.createElement('p');
|
||||
errorP.style.color = '#dc3545';
|
||||
errorP.textContent = message;
|
||||
body.appendChild(errorP);
|
||||
|
||||
const helpP = document.createElement('p');
|
||||
helpP.style.cssText = 'margin-top: 10px; color: #6c757d; font-size: 14px;';
|
||||
helpP.textContent = 'Please check the PDGA number and try again.';
|
||||
body.appendChild(helpP);
|
||||
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
footer.textContent = '';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'btn btn-cancel';
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.onclick = closeAddPlayerModal;
|
||||
footer.appendChild(closeBtn);
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function showInfoModal(message) {
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
document.getElementById('add-player-modal-header').textContent = 'Information';
|
||||
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
body.textContent = '';
|
||||
|
||||
const infoP = document.createElement('p');
|
||||
infoP.style.color = '#007bff';
|
||||
infoP.textContent = message;
|
||||
body.appendChild(infoP);
|
||||
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
footer.textContent = '';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'btn btn-cancel';
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.onclick = closeAddPlayerModal;
|
||||
footer.appendChild(closeBtn);
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function confirmAddPlayer() {
|
||||
if (!pendingPlayerData) {
|
||||
closeAddPlayerModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
body.textContent = 'Adding player...';
|
||||
document.getElementById('add-player-modal-footer').textContent = '';
|
||||
|
||||
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.textContent = '';
|
||||
const successP = document.createElement('p');
|
||||
successP.style.cssText = 'color: #28a745; text-align: center;';
|
||||
successP.textContent = `${data.player.name} has been added successfully!`;
|
||||
body.appendChild(successP);
|
||||
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
footer.textContent = '';
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.className = 'btn btn-confirm';
|
||||
okBtn.textContent = 'OK';
|
||||
okBtn.onclick = function() { closeAddPlayerModal(); location.reload(); };
|
||||
footer.appendChild(okBtn);
|
||||
|
||||
document.getElementById('pdga-number-input').value = '';
|
||||
pendingPlayerData = null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding player:', error);
|
||||
body.textContent = error.message;
|
||||
body.style.color = '#dc3545';
|
||||
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
footer.textContent = '';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'btn btn-cancel';
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.onclick = closeAddPlayerModal;
|
||||
footer.appendChild(closeBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function closeAddPlayerModal(event) {
|
||||
document.getElementById('add-player-modal').style.display = 'none';
|
||||
pendingPlayerData = null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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';
|
||||
htmx.ajax('GET', '/partials/ratings-table', '#ratings-table');
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
progressSection.style.display = 'none';
|
||||
tableDiv.textContent = 'Connection error. Please refresh the page.';
|
||||
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.textContent = '';
|
||||
|
||||
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';
|
||||
htmx.ajax('GET', '/partials/ratings-table', '#ratings-table');
|
||||
eventSource.close();
|
||||
button.textContent = originalText;
|
||||
button.style.pointerEvents = 'auto';
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
progressSection.style.display = 'none';
|
||||
tableDiv.textContent = 'Connection error. Please refresh the page.';
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const dbPath = process.env.DB_PATH || './ratings.db';
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
|
||||
function initializeDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pdga_number INTEGER UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
current_rating INTEGER,
|
||||
rating_change INTEGER,
|
||||
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_round_update DATETIME DEFAULT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
db.get("PRAGMA table_info(players)", (err, info) => {
|
||||
if (err) {
|
||||
console.error('Error checking table schema:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
db.all("PRAGMA table_info(players)", (err, columns) => {
|
||||
if (err) {
|
||||
console.error('Error getting table info:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLastRoundUpdate = columns.some(col => col.name === 'last_round_update');
|
||||
const hasPredictedRating = columns.some(col => col.name === 'predicted_rating');
|
||||
const hasStdDev = columns.some(col => col.name === 'std_dev');
|
||||
|
||||
if (!hasLastRoundUpdate) {
|
||||
console.log('Adding last_round_update column to players table...');
|
||||
db.run(`ALTER TABLE players ADD COLUMN last_round_update DATETIME DEFAULT NULL`, (err) => {
|
||||
if (err) console.error('Error adding last_round_update column:', err.message);
|
||||
else console.log('Successfully added last_round_update column');
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasPredictedRating) {
|
||||
console.log('Adding predicted_rating column to players table...');
|
||||
db.run(`ALTER TABLE players ADD COLUMN predicted_rating INTEGER DEFAULT NULL`, (err) => {
|
||||
if (err) console.error('Error adding predicted_rating column:', err.message);
|
||||
else console.log('Successfully added predicted_rating column');
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasStdDev) {
|
||||
console.log('Adding std_dev column to players table...');
|
||||
db.run(`ALTER TABLE players ADD COLUMN std_dev INTEGER DEFAULT NULL`, (err) => {
|
||||
if (err) console.error('Error adding std_dev column:', err.message);
|
||||
else console.log('Successfully added std_dev column');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS round_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id INTEGER NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
competition_name TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
FOREIGN KEY (player_id) REFERENCES players (id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS rating_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id INTEGER NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
FOREIGN KEY (player_id) REFERENCES players (id)
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS courses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
link TEXT UNIQUE NOT NULL,
|
||||
city TEXT,
|
||||
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
course_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
par INTEGER NOT NULL,
|
||||
mean_rating INTEGER,
|
||||
rating_count INTEGER DEFAULT 0,
|
||||
last_calculated DATETIME,
|
||||
FOREIGN KEY (course_id) REFERENCES courses (id),
|
||||
UNIQUE(course_id, name, par)
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
db.run(`ALTER TABLE layouts ADD COLUMN mean_rating INTEGER`, () => {
|
||||
db.run(`ALTER TABLE layouts ADD COLUMN rating_count INTEGER DEFAULT 0`, () => {
|
||||
db.run(`ALTER TABLE layouts ADD COLUMN last_calculated DATETIME`, () => {
|
||||
db.run(`ALTER TABLE layouts ADD COLUMN last_played DATE`, () => {
|
||||
console.log('Database initialized successfully');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkAndPopulateDatabase() {
|
||||
const fs = require('fs');
|
||||
const { scrapePDGARating } = require('./services/player-service');
|
||||
|
||||
try {
|
||||
const playerCount = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM players', [], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row.count);
|
||||
});
|
||||
});
|
||||
|
||||
if (playerCount > 0) {
|
||||
console.log(`✓ Database already has ${playerCount} players - skipping text file import`);
|
||||
console.log('📝 Note: pdga-numbers.txt is only used when database is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== Database is empty - populating from PDGA numbers file ===');
|
||||
|
||||
const pdgaNumbers = fs.readFileSync('pdga-numbers.txt', 'utf-8')
|
||||
.split('\n')
|
||||
.map(num => num.trim())
|
||||
.filter(num => num);
|
||||
|
||||
console.log(`Found ${pdgaNumbers.length} PDGA numbers in file`);
|
||||
|
||||
if (pdgaNumbers.length === 0) {
|
||||
console.log('⚠ No PDGA numbers found in file');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Populating database with players from file...');
|
||||
|
||||
for (let i = 0; i < pdgaNumbers.length; i++) {
|
||||
const pdgaNumber = pdgaNumbers[i];
|
||||
console.log(`[${i + 1}/${pdgaNumbers.length}] Adding PDGA ${pdgaNumber}...`);
|
||||
|
||||
try {
|
||||
const playerData = await scrapePDGARating(pdgaNumber);
|
||||
console.log(` ✓ Added ${playerData.name}`);
|
||||
|
||||
if (i < pdgaNumbers.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(` ✗ Failed to add PDGA ${pdgaNumber}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Database population complete ===');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during database population check:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { db, initializeDatabase, checkAndPopulateDatabase };
|
||||
@@ -0,0 +1,78 @@
|
||||
const { db } = require('../db');
|
||||
|
||||
function saveCourseToDB(courseData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO courses (name, link, city, last_updated)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
[courseData.name, courseData.link, courseData.city],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this.lastID);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getAllCoursesFromDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
'SELECT * FROM courses ORDER BY name ASC',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function saveLayoutToDB(courseId, layoutData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO layouts (course_id, name, par)
|
||||
VALUES (?, ?, ?)`,
|
||||
[courseId, layoutData.name, layoutData.par],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this.lastID);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getLayoutsForCourse(courseId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
'SELECT * FROM layouts WHERE course_id = ? ORDER BY last_played DESC, name ASC',
|
||||
[courseId],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function updateLayoutRating(courseId, layoutName, par, meanRating, ratingCount, lastPlayed = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`UPDATE layouts
|
||||
SET mean_rating = ?, rating_count = ?, last_calculated = datetime('now'), last_played = ?
|
||||
WHERE course_id = ? AND name = ? AND par = ?`,
|
||||
[meanRating, ratingCount, lastPlayed, courseId, layoutName, par],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this.changes);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveCourseToDB,
|
||||
getAllCoursesFromDB,
|
||||
saveLayoutToDB,
|
||||
getLayoutsForCourse,
|
||||
updateLayoutRating
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
const { db } = require('../db');
|
||||
const { parseDate } = require('../services/rating-calculator');
|
||||
|
||||
function getPlayerFromDB(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT * FROM players WHERE pdga_number = ?',
|
||||
[pdgaNumber],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function savePlayerToDB(playerData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO players (pdga_number, name, current_rating, rating_change, last_updated)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
[playerData.pdgaNumber, playerData.name, playerData.rating, playerData.ratingChange],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this.lastID);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getRatingHistoryFromDB(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return resolve(null);
|
||||
|
||||
db.all(
|
||||
'SELECT * FROM rating_history WHERE player_id = ? ORDER BY date ASC',
|
||||
[player.id],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveRatingHistoryToDB(pdgaNumber, ratingHistory) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return reject(new Error('Player not found'));
|
||||
|
||||
db.run('DELETE FROM rating_history WHERE player_id = ?', [player.id], (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
if (ratingHistory.length === 0) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
const total = ratingHistory.length;
|
||||
|
||||
ratingHistory.forEach(entry => {
|
||||
const parsedDate = parseDate(entry.date);
|
||||
|
||||
db.run(
|
||||
'INSERT INTO rating_history (player_id, date, rating) VALUES (?, ?, ?)',
|
||||
[player.id, parsedDate.toISOString().split('T')[0], entry.rating],
|
||||
(err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
completed++;
|
||||
if (completed === total) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getRoundHistoryFromDB(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return resolve([]);
|
||||
|
||||
db.all(
|
||||
'SELECT * FROM round_history WHERE player_id = ? ORDER BY date DESC',
|
||||
[player.id],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getLastRoundUpdateDate(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT last_round_update FROM players WHERE pdga_number = ?',
|
||||
[pdgaNumber],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.last_round_update : null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function updateLastRoundUpdateDate(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'UPDATE players SET last_round_update = CURRENT_TIMESTAMP WHERE pdga_number = ?',
|
||||
[pdgaNumber],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||
if (err) return reject(err);
|
||||
if (!player) return reject(new Error('Player not found'));
|
||||
|
||||
const processRounds = () => {
|
||||
if (roundData.length === 0) {
|
||||
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const stmt = db.prepare('INSERT OR REPLACE INTO round_history (player_id, date, competition_name, rating) VALUES (?, ?, ?, ?)');
|
||||
|
||||
for (const round of roundData) {
|
||||
stmt.run([player.id, round.date.toISOString().split('T')[0], round.competition || 'Unknown', round.rating]);
|
||||
}
|
||||
|
||||
stmt.finalize((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (updateErr) => {
|
||||
if (updateErr) reject(updateErr);
|
||||
else resolve();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!isIncremental) {
|
||||
db.run('DELETE FROM round_history WHERE player_id = ?', [player.id], (err) => {
|
||||
if (err) return reject(err);
|
||||
processRounds();
|
||||
});
|
||||
} else {
|
||||
processRounds();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'UPDATE players SET predicted_rating = ?, std_dev = ? WHERE pdga_number = ?',
|
||||
[predictedRating, stdDev, pdgaNumber],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPlayerFromDB,
|
||||
savePlayerToDB,
|
||||
getRatingHistoryFromDB,
|
||||
saveRatingHistoryToDB,
|
||||
getRoundHistoryFromDB,
|
||||
getLastRoundUpdateDate,
|
||||
updateLastRoundUpdateDate,
|
||||
saveRoundHistoryToDB,
|
||||
savePredictedRatingToDB
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../db');
|
||||
const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating } = require('../models/course');
|
||||
const { launchBrowser } = require('../scrapers/browser');
|
||||
const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer');
|
||||
|
||||
// Request locking to prevent concurrent scrapes of the same resource
|
||||
const activeScrapes = new Map();
|
||||
|
||||
router.get('/partials/course-table', async (req, res) => {
|
||||
try {
|
||||
const allCourses = await getAllCoursesFromDB();
|
||||
const query = req.query.q || '';
|
||||
let courses = allCourses;
|
||||
|
||||
if (query) {
|
||||
const q = query.toLowerCase();
|
||||
courses = allCourses.filter(c =>
|
||||
c.name.toLowerCase().includes(q) || c.city.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
res.render('../partials/course-table', { courses, query, total: allCourses.length });
|
||||
} catch (error) {
|
||||
res.status(500).send('<p>Error loading courses. Please try again.</p>');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/partials/course-layouts/:courseId', async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const layouts = await getLayoutsForCourse(courseId);
|
||||
res.render('../partials/course-layouts', { layouts, courseId });
|
||||
} catch (error) {
|
||||
console.error('Error loading course layouts:', error.message);
|
||||
res.status(500).send('<div class="no-layouts">Error loading layouts</div>');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/courses', async (req, res) => {
|
||||
try {
|
||||
const courses = await getAllCoursesFromDB();
|
||||
res.json(courses);
|
||||
} catch (error) {
|
||||
console.error('Error fetching courses:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch courses' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/layouts/:courseId', async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const layouts = await getLayoutsForCourse(courseId);
|
||||
res.json(layouts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching layouts:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch layouts' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/scrape-courses', async (req, res) => {
|
||||
req.setTimeout(600000);
|
||||
res.setTimeout(600000);
|
||||
|
||||
let browser = null;
|
||||
try {
|
||||
console.log('Starting course directory scraping...');
|
||||
|
||||
browser = await launchBrowser();
|
||||
|
||||
const courses = await scrapeCourseDirectory(browser);
|
||||
|
||||
await browser.close();
|
||||
browser = null;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
coursesFound: courses.length,
|
||||
message: `Successfully scraped ${courses.length} courses`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error scraping courses:', error.message);
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch (e) {}
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to scrape courses' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/scrape-layouts/:courseId', async (req, res) => {
|
||||
req.setTimeout(600000);
|
||||
res.setTimeout(600000);
|
||||
|
||||
const { courseId } = req.params;
|
||||
const lockKey = `layout-${courseId}`;
|
||||
|
||||
if (activeScrapes.has(lockKey)) {
|
||||
console.log(`⚠️ Scrape already in progress for course ${courseId}`);
|
||||
return res.status(409).json({
|
||||
error: 'Scrape already in progress for this course',
|
||||
message: 'Please wait for the current scrape to complete'
|
||||
});
|
||||
}
|
||||
|
||||
let browser = null;
|
||||
|
||||
const scrapePromise = (async () => {
|
||||
try {
|
||||
const course = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT * FROM courses WHERE id = ?', [courseId], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
|
||||
console.log(`Starting layout scraping for course: ${course.name}`);
|
||||
|
||||
browser = await launchBrowser();
|
||||
|
||||
const layouts = await scrapeCourseLayouts(browser, course.link, courseId);
|
||||
|
||||
console.log(`\n=== Starting event results scraping for ${course.name} ===`);
|
||||
|
||||
const courseIdInt = parseInt(courseId);
|
||||
const layoutData = layoutEventCache.get(courseIdInt);
|
||||
|
||||
if (!layoutData || layoutData.length === 0) {
|
||||
console.log('No event data found in cache, skipping event results scraping');
|
||||
await browser.close();
|
||||
browser = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
layoutsFound: layouts.length,
|
||||
message: `Successfully scraped ${layouts.length} layouts for ${course.name} (no events found)`
|
||||
};
|
||||
}
|
||||
|
||||
const eventGroups = {};
|
||||
layoutData.forEach(layout => {
|
||||
if (layout.eventUrl) {
|
||||
if (!eventGroups[layout.eventUrl]) {
|
||||
eventGroups[layout.eventUrl] = [];
|
||||
}
|
||||
eventGroups[layout.eventUrl].push(layout);
|
||||
}
|
||||
});
|
||||
|
||||
const allLayoutRatings = {};
|
||||
|
||||
let eventCount = 0;
|
||||
for (const eventUrl in eventGroups) {
|
||||
eventCount++;
|
||||
const eventLayouts = eventGroups[eventUrl];
|
||||
|
||||
const results = await scrapeEventResults(browser, eventUrl, eventLayouts);
|
||||
|
||||
for (const layoutKey in results) {
|
||||
const layoutDataResult = results[layoutKey];
|
||||
|
||||
if (!allLayoutRatings[layoutKey]) {
|
||||
allLayoutRatings[layoutKey] = {
|
||||
name: layoutDataResult.name,
|
||||
par: layoutDataResult.par,
|
||||
allRatings: [],
|
||||
latestDate: layoutDataResult.eventDate
|
||||
};
|
||||
} else {
|
||||
if (layoutDataResult.eventDate && (!allLayoutRatings[layoutKey].latestDate ||
|
||||
new Date(layoutDataResult.eventDate) > new Date(allLayoutRatings[layoutKey].latestDate))) {
|
||||
allLayoutRatings[layoutKey].latestDate = layoutDataResult.eventDate;
|
||||
}
|
||||
}
|
||||
|
||||
allLayoutRatings[layoutKey].allRatings.push(...layoutDataResult.ratings);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log(`\n=== Calculating final ratings for all layouts ===`);
|
||||
|
||||
let savedCount = 0;
|
||||
for (const layoutKey in allLayoutRatings) {
|
||||
const layoutDataResult = allLayoutRatings[layoutKey];
|
||||
|
||||
if (layoutDataResult.allRatings.length > 0) {
|
||||
const meanRating = Math.round(
|
||||
layoutDataResult.allRatings.reduce((sum, r) => sum + r, 0) / layoutDataResult.allRatings.length
|
||||
);
|
||||
|
||||
console.log(`Layout: ${layoutDataResult.name} (Par ${layoutDataResult.par})`);
|
||||
console.log(` Total ratings collected: ${layoutDataResult.allRatings.length}`);
|
||||
console.log(` Mean rating: ${meanRating}`);
|
||||
console.log(` Last played: ${layoutDataResult.latestDate || 'Unknown'}`);
|
||||
|
||||
try {
|
||||
const changes = await updateLayoutRating(
|
||||
courseIdInt,
|
||||
layoutDataResult.name,
|
||||
layoutDataResult.par,
|
||||
meanRating,
|
||||
layoutDataResult.allRatings.length,
|
||||
layoutDataResult.latestDate
|
||||
);
|
||||
if (changes > 0) {
|
||||
console.log(` ✓ Updated in database`);
|
||||
savedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` Error updating layout ${layoutDataResult.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
browser = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
layoutsFound: layouts.length,
|
||||
eventsProcessed: Object.keys(eventGroups).length,
|
||||
layoutsWithRatings: savedCount,
|
||||
message: `Successfully scraped ${layouts.length} layouts and processed ${Object.keys(eventGroups).length} events for ${course.name}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error scraping layouts:', error.message);
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch (e) {}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
activeScrapes.set(lockKey, scrapePromise);
|
||||
|
||||
try {
|
||||
const result = await scrapePromise;
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: 'Failed to scrape layouts',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
activeScrapes.delete(lockKey);
|
||||
console.log(`✓ Released lock for course ${courseId}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/scrape-event-results/:courseId', async (req, res) => {
|
||||
req.setTimeout(600000);
|
||||
res.setTimeout(600000);
|
||||
|
||||
let browser = null;
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const courseIdInt = parseInt(courseId);
|
||||
|
||||
const layoutData = layoutEventCache.get(courseIdInt);
|
||||
|
||||
if (!layoutData || layoutData.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No layout data found in cache. Please scrape layouts first.'
|
||||
});
|
||||
}
|
||||
|
||||
browser = await launchBrowser();
|
||||
|
||||
const eventGroups = {};
|
||||
layoutData.forEach(layout => {
|
||||
if (layout.eventUrl) {
|
||||
if (!eventGroups[layout.eventUrl]) {
|
||||
eventGroups[layout.eventUrl] = [];
|
||||
}
|
||||
eventGroups[layout.eventUrl].push(layout);
|
||||
}
|
||||
});
|
||||
|
||||
const allLayoutRatings = {};
|
||||
|
||||
let eventCount = 0;
|
||||
for (const eventUrl in eventGroups) {
|
||||
eventCount++;
|
||||
const eventLayouts = eventGroups[eventUrl];
|
||||
|
||||
const results = await scrapeEventResults(browser, eventUrl, eventLayouts);
|
||||
|
||||
for (const layoutKey in results) {
|
||||
const ld = results[layoutKey];
|
||||
|
||||
if (!allLayoutRatings[layoutKey]) {
|
||||
allLayoutRatings[layoutKey] = {
|
||||
name: ld.name,
|
||||
par: ld.par,
|
||||
allRatings: [],
|
||||
latestDate: ld.eventDate
|
||||
};
|
||||
} else {
|
||||
if (ld.eventDate && (!allLayoutRatings[layoutKey].latestDate ||
|
||||
new Date(ld.eventDate) > new Date(allLayoutRatings[layoutKey].latestDate))) {
|
||||
allLayoutRatings[layoutKey].latestDate = ld.eventDate;
|
||||
}
|
||||
}
|
||||
|
||||
allLayoutRatings[layoutKey].allRatings.push(...ld.ratings);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
browser = null;
|
||||
|
||||
console.log(`\n=== Calculating final ratings for all layouts ===`);
|
||||
|
||||
let savedCount = 0;
|
||||
for (const layoutKey in allLayoutRatings) {
|
||||
const ld = allLayoutRatings[layoutKey];
|
||||
|
||||
if (ld.allRatings.length > 0) {
|
||||
const meanRating = Math.round(
|
||||
ld.allRatings.reduce((sum, r) => sum + r, 0) / ld.allRatings.length
|
||||
);
|
||||
|
||||
console.log(`Layout: ${ld.name} (Par ${ld.par})`);
|
||||
console.log(` Total ratings collected: ${ld.allRatings.length}`);
|
||||
console.log(` Mean rating: ${meanRating}`);
|
||||
console.log(` Last played: ${ld.latestDate || 'Unknown'}`);
|
||||
|
||||
try {
|
||||
const changes = await updateLayoutRating(
|
||||
courseIdInt,
|
||||
ld.name,
|
||||
ld.par,
|
||||
meanRating,
|
||||
ld.allRatings.length,
|
||||
ld.latestDate
|
||||
);
|
||||
if (changes > 0) {
|
||||
console.log(` ✓ Updated in database`);
|
||||
savedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` Error updating layout ${ld.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
eventsProcessed: Object.keys(eventGroups).length,
|
||||
uniqueLayouts: Object.keys(allLayoutRatings).length,
|
||||
layoutsSaved: savedCount,
|
||||
message: `Processed ${Object.keys(eventGroups).length} events, updated ${savedCount} layouts`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error scraping event results:', error.message);
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch (e) {}
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to scrape event results' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('index');
|
||||
});
|
||||
|
||||
router.get('/courses', (req, res) => {
|
||||
res.render('courses');
|
||||
});
|
||||
|
||||
// Keep old URL working
|
||||
router.get('/courses.html', (req, res) => {
|
||||
res.redirect('/courses');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,489 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../db');
|
||||
const { getPlayerFromDB, savePlayerToDB, getRatingHistoryFromDB, saveRatingHistoryToDB, getRoundHistoryFromDB, getLastRoundUpdateDate, updateLastRoundUpdateDate, saveRoundHistoryToDB, savePredictedRatingToDB } = require('../models/player');
|
||||
const { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHistory } = require('../scrapers/player-http');
|
||||
const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer');
|
||||
const { launchBrowser } = require('../scrapers/browser');
|
||||
const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service');
|
||||
const { calculatePredictedRating } = require('../services/rating-calculator');
|
||||
|
||||
router.get('/partials/ratings-table', async (req, res) => {
|
||||
try {
|
||||
const ratings = await getAllRatingsFromDB();
|
||||
res.render('../partials/ratings-table', { ratings });
|
||||
} catch (error) {
|
||||
res.status(500).send('<p>Error loading ratings. Please try again.</p>');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/partials/player-history/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
|
||||
let history = await getRatingHistoryFromDB(pdgaNumber);
|
||||
if (!history || history.length === 0) {
|
||||
const html = await fetchRatingHistory(pdgaNumber);
|
||||
history = parseRatingHistory(html);
|
||||
try {
|
||||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to save rating history:', dbErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedHistory = (history || []).map(row => ({
|
||||
date: row.date,
|
||||
rating: row.rating,
|
||||
displayDate: new Date(row.date).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}));
|
||||
|
||||
res.render('../partials/player-history', { pdgaNumber, history: formattedHistory });
|
||||
} catch (error) {
|
||||
console.error('Error loading player history:', error.message);
|
||||
res.status(500).send('<div class="loading-chart">Error loading rating history</div>');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/ratings', async (req, res) => {
|
||||
try {
|
||||
const ratings = await getAllRatingsFromDB();
|
||||
res.json(ratings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch ratings' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/ratings/progress', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
});
|
||||
|
||||
const progressCallback = (progress) => {
|
||||
res.write(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
};
|
||||
|
||||
getAllRatingsFromDB(progressCallback).then(ratings => {
|
||||
res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`);
|
||||
res.end();
|
||||
}).catch(error => {
|
||||
res.write(`data: ${JSON.stringify({ status: 'error', error: error.message })}\n\n`);
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/api/populate-database', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
|
||||
const progressCallback = (progress) => {
|
||||
res.write(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
};
|
||||
|
||||
console.log('=== Starting database population from database players ===');
|
||||
|
||||
refreshAllPlayersInDB(progressCallback).then(ratings => {
|
||||
console.log(`=== Database population complete: ${ratings.length} players refreshed ===`);
|
||||
res.write(`data: ${JSON.stringify({ status: 'complete', ratings, message: `Successfully refreshed ${ratings.length} players` })}\n\n`);
|
||||
res.end();
|
||||
}).catch(error => {
|
||||
console.error('Error populating database:', error);
|
||||
res.write(`data: ${JSON.stringify({ status: 'error', message: error.message })}\n\n`);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/api/database-status', async (req, res) => {
|
||||
try {
|
||||
const playerCount = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM players', [], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row.count);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
playersInDB: playerCount,
|
||||
needsPopulation: playerCount === 0
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to check database status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/load-all-players', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||||
});
|
||||
|
||||
const progressCallback = (progress) => {
|
||||
res.write(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
};
|
||||
|
||||
refreshAllPlayersInDB(progressCallback).then(ratings => {
|
||||
res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`);
|
||||
res.end();
|
||||
}).catch(error => {
|
||||
res.write(`data: ${JSON.stringify({ status: 'error', error: error.message })}\n\n`);
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/api/rating-history/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
|
||||
const cachedHistory = await getRatingHistoryFromDB(pdgaNumber);
|
||||
if (cachedHistory && cachedHistory.length > 0) {
|
||||
console.log(`Using cached rating history from DB for PDGA ${pdgaNumber}`);
|
||||
const formattedHistory = cachedHistory.map(row => ({
|
||||
date: row.date,
|
||||
rating: row.rating,
|
||||
displayDate: new Date(row.date).toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}));
|
||||
|
||||
res.json({
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
history: formattedHistory
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Fetching rating history for PDGA ${pdgaNumber}...`);
|
||||
const html = await fetchRatingHistory(pdgaNumber);
|
||||
const history = parseRatingHistory(html);
|
||||
|
||||
try {
|
||||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||||
console.log(`Saved rating history for PDGA ${pdgaNumber} to database`);
|
||||
} catch (dbErr) {
|
||||
console.error(`Failed to save rating history to database:`, dbErr.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
history
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching rating history:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch rating history' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/clear-cache', (req, res) => {
|
||||
try {
|
||||
db.run('UPDATE players SET last_updated = datetime("now", "-25 hours"), last_round_update = NULL', (err) => {
|
||||
if (err) {
|
||||
console.error('Error clearing database cache:', err);
|
||||
res.status(500).json({ error: 'Failed to clear database cache' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Database cache cleared - all players will be refreshed on next request');
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cache cleared - database reset'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
res.status(500).json({ error: 'Failed to clear cache' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/search-player/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
console.log(`Searching for player with PDGA number ${pdgaNumber}`);
|
||||
|
||||
const existingPlayer = await getPlayerFromDB(pdgaNumber);
|
||||
if (existingPlayer) {
|
||||
return res.json({
|
||||
alreadyExists: true,
|
||||
player: {
|
||||
pdgaNumber: existingPlayer.pdga_number,
|
||||
name: existingPlayer.name,
|
||||
rating: existingPlayer.current_rating,
|
||||
ratingChange: existingPlayer.rating_change
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||
const playerData = parsePlayerData(html, pdgaNumber);
|
||||
|
||||
if (playerData.name === 'Unknown' || !playerData.name) {
|
||||
return res.status(404).json({ error: 'Player not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
alreadyExists: false,
|
||||
player: playerData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching for player:', error.message);
|
||||
res.status(500).json({ error: 'Failed to search for player' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/add-player', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.body;
|
||||
|
||||
if (!pdgaNumber) {
|
||||
return res.status(400).json({ error: 'PDGA number is required' });
|
||||
}
|
||||
|
||||
console.log(`Adding player with PDGA number ${pdgaNumber}`);
|
||||
|
||||
const existingPlayer = await getPlayerFromDB(pdgaNumber);
|
||||
if (existingPlayer) {
|
||||
return res.status(409).json({
|
||||
error: 'Player already exists',
|
||||
player: {
|
||||
pdgaNumber: existingPlayer.pdga_number,
|
||||
name: existingPlayer.name,
|
||||
rating: existingPlayer.current_rating
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||
const playerData = parsePlayerData(html, pdgaNumber);
|
||||
|
||||
if (playerData.name === 'Unknown' || !playerData.name) {
|
||||
return res.status(404).json({ error: 'Player not found' });
|
||||
}
|
||||
|
||||
await savePlayerToDB(playerData);
|
||||
|
||||
console.log(`Successfully added player: ${playerData.name} (#${pdgaNumber})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
player: playerData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding player:', error.message);
|
||||
res.status(500).json({ error: 'Failed to add player' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/refresh-player/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
console.log(`Manually refreshing player data for PDGA ${pdgaNumber}`);
|
||||
|
||||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||
const playerData = parsePlayerData(html, pdgaNumber);
|
||||
|
||||
await savePlayerToDB(playerData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
player: playerData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error refreshing player data:', error.message);
|
||||
res.status(500).json({ error: 'Failed to refresh player data' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/refresh-rating-history/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
console.log(`=== Manually refreshing rating history for PDGA ${pdgaNumber} ===`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const html = await fetchRatingHistory(pdgaNumber);
|
||||
const fetchTime = Date.now() - startTime;
|
||||
|
||||
console.log(`HTML fetch completed in ${fetchTime}ms, received ${html.length} bytes`);
|
||||
|
||||
const parseStartTime = Date.now();
|
||||
const history = parseRatingHistory(html);
|
||||
const parseTime = Date.now() - parseStartTime;
|
||||
|
||||
console.log(`Parsing completed in ${parseTime}ms, found ${history.length} history entries`);
|
||||
|
||||
if (history.length > 0) {
|
||||
console.log('Sample history entries:', history.slice(0, 3));
|
||||
} else {
|
||||
console.log('No history entries found. HTML sample:', html.substring(0, 500));
|
||||
}
|
||||
|
||||
const dbStartTime = Date.now();
|
||||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||||
const dbTime = Date.now() - dbStartTime;
|
||||
|
||||
console.log(`Database save completed in ${dbTime}ms`);
|
||||
|
||||
const formattedHistory = history.map(entry => ({
|
||||
date: entry.date,
|
||||
rating: entry.rating,
|
||||
displayDate: entry.displayDate
|
||||
}));
|
||||
|
||||
console.log(`=== Rating history refresh completed for PDGA ${pdgaNumber} ===`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
history: formattedHistory
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`=== Error refreshing rating history for PDGA ${req.params.pdgaNumber} ===`);
|
||||
console.error('Error type:', error.constructor.name);
|
||||
console.error('Error message:', error.message);
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to refresh rating history',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
|
||||
req.setTimeout(600000);
|
||||
res.setTimeout(600000);
|
||||
|
||||
let browser = null;
|
||||
const { pdgaNumber } = req.params;
|
||||
try {
|
||||
const lastRoundUpdate = await getLastRoundUpdateDate(pdgaNumber);
|
||||
const sinceDate = lastRoundUpdate ? new Date(lastRoundUpdate) : null;
|
||||
|
||||
if (sinceDate) {
|
||||
const hoursSinceUpdate = (Date.now() - sinceDate.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursSinceUpdate < 24) {
|
||||
const hoursRemaining = Math.ceil(24 - hoursSinceUpdate);
|
||||
return res.status(429).json({
|
||||
error: 'Rate limit exceeded',
|
||||
message: `Prediction can only be refreshed once every 24 hours. Please try again in ${hoursRemaining} hour(s).`,
|
||||
lastUpdate: sinceDate.toISOString(),
|
||||
hoursRemaining: hoursRemaining
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isIncremental = !!sinceDate;
|
||||
|
||||
console.log(`${isIncremental ? 'Incrementally updating' : 'Fully refreshing'} round history for PDGA ${pdgaNumber}${sinceDate ? ` since ${sinceDate.toDateString()}` : ''}`);
|
||||
|
||||
browser = await launchBrowser();
|
||||
|
||||
let officialHistory;
|
||||
try {
|
||||
officialHistory = await getOfficialRatingHistory(browser, pdgaNumber);
|
||||
if (officialHistory.length > 0) {
|
||||
await saveRatingHistoryToDB(pdgaNumber, officialHistory);
|
||||
}
|
||||
} catch (historyError) {
|
||||
console.error('Failed to fetch official history:', historyError.message);
|
||||
officialHistory = [];
|
||||
}
|
||||
|
||||
let allRounds = [];
|
||||
try {
|
||||
console.log(`Using optimized approach: /details + new tournaments only for PDGA ${pdgaNumber}...`);
|
||||
allRounds = await getOptimizedPlayerRounds(browser, pdgaNumber);
|
||||
|
||||
if (allRounds.length > 0) {
|
||||
const roundsForDB = allRounds.map(round => ({
|
||||
rating: round.rating,
|
||||
date: round.date,
|
||||
competition: round.competition
|
||||
}));
|
||||
|
||||
await saveRoundHistoryToDB(pdgaNumber, roundsForDB, false);
|
||||
console.log(`✓ Saved ${allRounds.length} rounds using optimized approach`);
|
||||
|
||||
await updateLastRoundUpdateDate(pdgaNumber);
|
||||
} else {
|
||||
console.log('ℹ No rounds found');
|
||||
}
|
||||
} catch (detailsError) {
|
||||
console.error('Failed to fetch rounds using optimized approach:', detailsError.message);
|
||||
allRounds = [];
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
browser = null;
|
||||
|
||||
const dbRounds = await getRoundHistoryFromDB(pdgaNumber);
|
||||
const roundsForPrediction = dbRounds.map(round => ({
|
||||
rating: round.rating,
|
||||
date: new Date(round.date),
|
||||
competition: round.competition_name
|
||||
}));
|
||||
|
||||
const result = calculatePredictedRating(roundsForPrediction);
|
||||
|
||||
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
|
||||
|
||||
const officialCount = allRounds.filter(r => r.source === 'official').length;
|
||||
const newCount = allRounds.filter(r => r.source === 'new').length;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
predictedRating: result.rating,
|
||||
stdDev: result.stdDev,
|
||||
debugLog: result.debugLog,
|
||||
totalRounds: roundsForPrediction.length,
|
||||
officialRounds: officialCount,
|
||||
newRounds: newCount,
|
||||
approach: 'optimized',
|
||||
message: `Used /details (${officialCount} rounds) + new tournaments (${newCount} rounds)`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`=== Error refreshing round history for PDGA ${pdgaNumber} ===`);
|
||||
console.error('Error type:', error.constructor.name);
|
||||
console.error('Error message:', error.message);
|
||||
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (closeError) {
|
||||
console.error('Error closing browser:', closeError.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to refresh round history',
|
||||
details: error.message,
|
||||
errorType: error.constructor.name,
|
||||
timestamp: new Date().toISOString(),
|
||||
suggestion: error.message.includes('socket hang up') ?
|
||||
'Rate limited by PDGA - try again in a few minutes.' :
|
||||
error.message.includes('timeout') ?
|
||||
'PDGA pages are loading slowly - try again later.' :
|
||||
'Tournament scraping failed - check server logs for details'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,21 @@
|
||||
const puppeteer = require('puppeteer-extra');
|
||||
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
async function launchBrowser() {
|
||||
return await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
'--disable-gpu'
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { launchBrowser };
|
||||
@@ -0,0 +1,349 @@
|
||||
const { saveCourseToDB, saveLayoutToDB } = require('../models/course');
|
||||
|
||||
// In-memory cache for layout-division-event mapping
|
||||
const layoutEventCache = new Map();
|
||||
|
||||
function getLayoutEventCache() {
|
||||
return layoutEventCache;
|
||||
}
|
||||
|
||||
async function scrapeCourseDirectory(browser) {
|
||||
console.log('=== Scraping Swedish courses from PDGA course directory ===');
|
||||
const page = await browser.newPage();
|
||||
const allCourses = [];
|
||||
let pageNumber = 0;
|
||||
let hasMorePages = true;
|
||||
|
||||
try {
|
||||
while (hasMorePages) {
|
||||
const url = `https://www.pdga.com/course-directory/advanced?title=&field_course_location_country=SE&field_course_location_locality=&field_course_location_administrative_area=All&field_course_location_postal_code=&field_course_type_value=All&rating_value=All&field_course_holes_value=18-100&field_course_total_length_value=All&field_course_target_type_value=All&field_course_tee_type_value=All&field_location_type_value=All&field_course_camping_value=All&field_course_facilities_value=All&field_course_fees_value=All&field_course_handicap_value=All&field_course_private_value=All&field_course_signage_value=All&field_cart_friendly_value=All&page=${pageNumber}`;
|
||||
|
||||
console.log(`Scraping page ${pageNumber}...`);
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const courses = await page.evaluate(() => {
|
||||
const courseData = [];
|
||||
const rows = document.querySelectorAll('table tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const titleCell = row.querySelector('td.views-field-title');
|
||||
const locationCell = row.querySelector('td.views-field-field-course-location');
|
||||
|
||||
if (titleCell) {
|
||||
const link = titleCell.querySelector('a');
|
||||
if (link) {
|
||||
courseData.push({
|
||||
name: link.innerText.trim(),
|
||||
link: 'https://www.pdga.com' + link.getAttribute('href'),
|
||||
city: locationCell ? locationCell.innerText.trim() : 'Unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return courseData;
|
||||
});
|
||||
|
||||
if (courses.length === 0) {
|
||||
console.log(`No courses found on page ${pageNumber}, stopping pagination`);
|
||||
hasMorePages = false;
|
||||
} else {
|
||||
console.log(`Found ${courses.length} courses on page ${pageNumber}`);
|
||||
allCourses.push(...courses);
|
||||
|
||||
for (const course of courses) {
|
||||
try {
|
||||
await saveCourseToDB(course);
|
||||
console.log(`✓ Saved course: ${course.name} (${course.city})`);
|
||||
} catch (err) {
|
||||
console.error(`Error saving course ${course.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
|
||||
if (hasMorePages) {
|
||||
console.log('Waiting 2s before next page...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Total courses scraped: ${allCourses.length} across ${pageNumber} pages`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error scraping course directory:', error.message);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return allCourses;
|
||||
}
|
||||
|
||||
async function scrapeCourseLayouts(browser, courseLink, courseId) {
|
||||
console.log(`\n=== Scraping layouts from: ${courseLink} ===`);
|
||||
const page = await browser.newPage();
|
||||
const layouts = [];
|
||||
|
||||
try {
|
||||
await page.goto(courseLink, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const layoutsTabClicked = await page.evaluate(() => {
|
||||
const selectors = [
|
||||
'a.quicktabs-tab-course_node-2',
|
||||
'li.quicktabs-tab-course_node-2 a',
|
||||
'a[href*="layouts"]',
|
||||
'.quicktabs-tabs a',
|
||||
'ul.quicktabs-tabs a',
|
||||
'.quicktabs-wrapper a'
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const tabs = document.querySelectorAll(selector);
|
||||
for (const tab of tabs) {
|
||||
const text = tab.innerText?.trim();
|
||||
if (text && (text.includes('Layouts') || text.includes('Layout'))) {
|
||||
tab.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (layoutsTabClicked) {
|
||||
console.log('✓ Layouts tab found and clicked');
|
||||
await page.waitForTimeout(3000);
|
||||
} else {
|
||||
console.warn('⚠️ Layouts tab not found - may be on a single-layout course page');
|
||||
}
|
||||
|
||||
const extractedLayouts = await page.evaluate(() => {
|
||||
const layoutData = [];
|
||||
const tournamentsDiv = document.querySelector('div.tournaments');
|
||||
|
||||
if (!tournamentsDiv) {
|
||||
return layoutData;
|
||||
}
|
||||
|
||||
const tournamentCourses = tournamentsDiv.querySelectorAll('details.tournament-course');
|
||||
|
||||
tournamentCourses.forEach((details) => {
|
||||
const resultsDiv = details.querySelector('div.results');
|
||||
const resultsLink = resultsDiv ? resultsDiv.querySelector('a') : null;
|
||||
const eventUrl = resultsLink ? resultsLink.getAttribute('href') : null;
|
||||
const fullEventUrl = eventUrl ? 'https://www.pdga.com' + eventUrl : null;
|
||||
|
||||
const layoutsDiv = details.querySelector('div.layouts');
|
||||
if (!layoutsDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutDivs = layoutsDiv.querySelectorAll('div.layout');
|
||||
|
||||
layoutDivs.forEach((layoutDiv) => {
|
||||
const h4WithClass = layoutDiv.querySelector('h4.title');
|
||||
const h4Any = layoutDiv.querySelector('h4');
|
||||
|
||||
let layoutName = '';
|
||||
if (h4WithClass) {
|
||||
layoutName = (h4WithClass.textContent || h4WithClass.innerText || '').trim();
|
||||
} else if (h4Any) {
|
||||
layoutName = (h4Any.textContent || h4Any.innerText || '').trim();
|
||||
}
|
||||
|
||||
const allText = layoutDiv.textContent || layoutDiv.innerText || '';
|
||||
|
||||
const parPatterns = [
|
||||
/Par[:\s]+(\d+)/i,
|
||||
/Par\s*=\s*(\d+)/i,
|
||||
/\(Par\s+(\d+)\)/i,
|
||||
/Total Par:\s*(\d+)/i
|
||||
];
|
||||
|
||||
let par = null;
|
||||
for (const pattern of parPatterns) {
|
||||
const match = allText.match(pattern);
|
||||
if (match) {
|
||||
par = parseInt(match[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const divisionsLi = layoutDiv.querySelector('li.divisions');
|
||||
let divisions = [];
|
||||
if (divisionsLi) {
|
||||
const divisionsText = (divisionsLi.textContent || '').replace('Divisions:', '').trim();
|
||||
divisions = divisionsText.split(/[,\s]+/).filter(d => d.length > 0);
|
||||
}
|
||||
|
||||
if (layoutName && par && !isNaN(par) && par > 0) {
|
||||
layoutData.push({
|
||||
name: layoutName,
|
||||
par: par,
|
||||
divisions: divisions,
|
||||
eventUrl: fullEventUrl
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return layoutData;
|
||||
});
|
||||
|
||||
layouts.push(...extractedLayouts);
|
||||
|
||||
const courseIdInt = typeof courseId === 'string' ? parseInt(courseId) : courseId;
|
||||
layoutEventCache.set(courseIdInt, layouts);
|
||||
|
||||
console.log(`✓ Successfully parsed ${layouts.length} layouts from course page`);
|
||||
|
||||
const uniqueLayouts = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const layout of layouts) {
|
||||
const key = `${layout.name}|${layout.par}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueLayouts.push(layout);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueLayouts.length < layouts.length) {
|
||||
console.log(`ℹ️ Deduplicated to ${uniqueLayouts.length} unique layouts`);
|
||||
}
|
||||
|
||||
for (const layout of uniqueLayouts) {
|
||||
try {
|
||||
await saveLayoutToDB(courseId, layout);
|
||||
console.log(` ✓ Saved layout: ${layout.name} (Par ${layout.par})`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ Error saving layout ${layout.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error scraping course layouts:', error.message);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return layouts;
|
||||
}
|
||||
|
||||
async function scrapeEventResults(browser, eventUrl, layoutsWithDivisions) {
|
||||
const page = await browser.newPage();
|
||||
const layoutRatings = {};
|
||||
|
||||
try {
|
||||
await page.goto(eventUrl, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const eventDateRaw = await page.evaluate(() => {
|
||||
const allText = document.body.textContent;
|
||||
const datePattern = /\d{1,2}-[A-Z][a-z]{2}-\d{4}/;
|
||||
const match = allText.match(datePattern);
|
||||
return match ? match[0] : null;
|
||||
});
|
||||
|
||||
let eventDate = null;
|
||||
if (eventDateRaw) {
|
||||
try {
|
||||
const parsedDate = new Date(eventDateRaw);
|
||||
if (!isNaN(parsedDate.getTime())) {
|
||||
eventDate = parsedDate.toISOString().split('T')[0];
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore date parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
for (const layout of layoutsWithDivisions) {
|
||||
const layoutKey = `${layout.name}|${layout.par}`;
|
||||
const ratingsForLayout = [];
|
||||
|
||||
for (const division of layout.divisions) {
|
||||
const divisionData = await page.evaluate((divisionName, targetPar) => {
|
||||
const divisionH3 = document.querySelector(`h3#${divisionName}`);
|
||||
if (!divisionH3) {
|
||||
return { found: false, ratings: [] };
|
||||
}
|
||||
|
||||
const detailsTag = divisionH3.closest('details');
|
||||
if (!detailsTag) {
|
||||
return { found: false, ratings: [] };
|
||||
}
|
||||
|
||||
const table = detailsTag.querySelector('table.results');
|
||||
if (!table) {
|
||||
return { found: false, ratings: [] };
|
||||
}
|
||||
|
||||
const ratings = [];
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const roundCells = row.querySelectorAll('td.round');
|
||||
|
||||
roundCells.forEach(roundCell => {
|
||||
const scoreText = (roundCell.textContent || '').trim();
|
||||
const scoreMatch = scoreText.match(/^(\d+)$/);
|
||||
|
||||
if (scoreMatch) {
|
||||
const scoreValue = parseInt(scoreMatch[1]);
|
||||
|
||||
if (scoreValue === targetPar) {
|
||||
const ratingCell = roundCell.nextElementSibling;
|
||||
|
||||
if (ratingCell && ratingCell.classList.contains('round-rating')) {
|
||||
const ratingText = (ratingCell.textContent || '').trim();
|
||||
const rating = parseInt(ratingText);
|
||||
|
||||
if (!isNaN(rating) && rating > 0) {
|
||||
ratings.push(rating);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { found: true, ratings: ratings };
|
||||
}, division, layout.par);
|
||||
|
||||
if (divisionData.found && divisionData.ratings.length > 0) {
|
||||
ratingsForLayout.push(...divisionData.ratings);
|
||||
}
|
||||
}
|
||||
|
||||
if (ratingsForLayout.length > 0) {
|
||||
const meanRating = ratingsForLayout.reduce((sum, r) => sum + r, 0) / ratingsForLayout.length;
|
||||
layoutRatings[layoutKey] = {
|
||||
name: layout.name,
|
||||
par: layout.par,
|
||||
ratings: ratingsForLayout,
|
||||
count: ratingsForLayout.length,
|
||||
meanRating: Math.round(meanRating),
|
||||
eventDate: eventDate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error scraping event results:', error.message);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return layoutRatings;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
layoutEventCache,
|
||||
getLayoutEventCache,
|
||||
scrapeCourseDirectory,
|
||||
scrapeCourseLayouts,
|
||||
scrapeEventResults
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
const https = require('https');
|
||||
|
||||
async function fetchPlayerDataHTTP(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'www.pdga.com',
|
||||
port: 443,
|
||||
path: `/player/${pdgaNumber}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
},
|
||||
timeout: 30000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve(data);
|
||||
} else {
|
||||
const rateLimitInfo = {
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers
|
||||
};
|
||||
|
||||
console.log(`PDGA Response Status for #${pdgaNumber}: ${res.statusCode}`);
|
||||
console.log('Response Headers:', JSON.stringify(res.headers, null, 2));
|
||||
|
||||
if (res.headers['retry-after']) {
|
||||
console.log(`Retry-After header: ${res.headers['retry-after']}`);
|
||||
}
|
||||
if (res.headers['x-ratelimit-limit']) {
|
||||
console.log(`Rate Limit: ${res.headers['x-ratelimit-limit']}`);
|
||||
}
|
||||
if (res.headers['x-ratelimit-remaining']) {
|
||||
console.log(`Rate Limit Remaining: ${res.headers['x-ratelimit-remaining']}`);
|
||||
}
|
||||
if (res.headers['x-ratelimit-reset']) {
|
||||
console.log(`Rate Limit Reset: ${res.headers['x-ratelimit-reset']}`);
|
||||
}
|
||||
|
||||
const error = new Error(`HTTP ${res.statusCode}`);
|
||||
error.rateLimitInfo = rateLimitInfo;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(`Request error for PDGA #${pdgaNumber}:`, error.code, error.message);
|
||||
if (error.code === 'ECONNRESET') {
|
||||
console.log('Connection reset - likely rate limited by PDGA');
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.setTimeout(30000);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parsePlayerData(html, pdgaNumber) {
|
||||
try {
|
||||
const nameMatch = html.match(/<title>([^<]+?)\s*\|\s*Professional Disc Golf Association/i);
|
||||
const name = nameMatch ? nameMatch[1].trim() : 'Unknown';
|
||||
|
||||
const ratingMatch = html.match(/Current Rating:[^>]*>\s*(\d+)/i);
|
||||
const rating = ratingMatch ? parseInt(ratingMatch[1]) : 0;
|
||||
|
||||
const changeMatch = html.match(/Current Rating:[\s\S]*?([+\-]\d+)[\s\S]*?\(as of/i);
|
||||
const ratingChange = changeMatch ? parseInt(changeMatch[1]) : null;
|
||||
|
||||
return {
|
||||
pdgaNumber,
|
||||
name: name.replace(/\s*#\d+$/, ''),
|
||||
rating,
|
||||
ratingChange,
|
||||
predictedRating: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error parsing data for PDGA ${pdgaNumber}:`, error.message);
|
||||
return {
|
||||
pdgaNumber,
|
||||
name: 'Error',
|
||||
rating: 0,
|
||||
ratingChange: null,
|
||||
predictedRating: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRatingHistory(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'www.pdga.com',
|
||||
port: 443,
|
||||
path: `/player/${pdgaNumber}/history`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
},
|
||||
timeout: 30000
|
||||
};
|
||||
|
||||
console.log(`Fetching rating history for PDGA #${pdgaNumber} from: https://www.pdga.com/player/${pdgaNumber}/history`);
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log(`Rating history request successful for PDGA #${pdgaNumber}`);
|
||||
resolve(data);
|
||||
} else {
|
||||
console.log(`Rating History Error for PDGA #${pdgaNumber}:`);
|
||||
console.log(`Status: ${res.statusCode}`);
|
||||
console.log('Response Headers:', JSON.stringify(res.headers, null, 2));
|
||||
|
||||
if (res.headers['retry-after']) {
|
||||
console.log(`Retry-After: ${res.headers['retry-after']} seconds`);
|
||||
}
|
||||
if (res.headers['x-ratelimit-limit']) {
|
||||
console.log(`Rate Limit: ${res.headers['x-ratelimit-limit']}`);
|
||||
}
|
||||
if (res.headers['x-ratelimit-remaining']) {
|
||||
console.log(`Rate Limit Remaining: ${res.headers['x-ratelimit-remaining']}`);
|
||||
}
|
||||
|
||||
if (data.length > 0) {
|
||||
console.log(`Partial response received (${data.length} bytes):`, data.substring(0, 200));
|
||||
}
|
||||
|
||||
const error = new Error(`HTTP ${res.statusCode} for rating history`);
|
||||
error.statusCode = res.statusCode;
|
||||
error.headers = res.headers;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log(`Rating history request error for PDGA #${pdgaNumber}:`, {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
errno: error.errno,
|
||||
syscall: error.syscall
|
||||
});
|
||||
|
||||
if (error.code === 'ECONNRESET') {
|
||||
console.log('Connection reset on rating history - likely rate limited by PDGA');
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
console.log('Connection refused - PDGA server may be blocking requests');
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT') {
|
||||
console.log('Request timed out - server may be overloaded');
|
||||
}
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log(`Rating history request timeout for PDGA #${pdgaNumber} after 30s`);
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
req.setTimeout(30000);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseRatingHistory(html) {
|
||||
const history = [];
|
||||
|
||||
const rowMatches = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi);
|
||||
|
||||
if (rowMatches) {
|
||||
for (const row of rowMatches) {
|
||||
if (row.includes('<th') || !row.includes('<td')) continue;
|
||||
|
||||
const cellMatches = row.match(/<td[^>]*>(.*?)<\/td>/gi);
|
||||
|
||||
if (cellMatches && cellMatches.length >= 2) {
|
||||
const dateText = cellMatches[0].replace(/<[^>]*>/g, '').trim();
|
||||
const ratingText = cellMatches[1].replace(/<[^>]*>/g, '').trim();
|
||||
|
||||
const dateMatch = dateText.match(/(\d{1,2})-([A-Za-z]{3})-(\d{4})/);
|
||||
if (dateMatch && !isNaN(parseInt(ratingText))) {
|
||||
const [, day, month, year] = dateMatch;
|
||||
const monthMap = {
|
||||
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||||
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||||
};
|
||||
|
||||
const date = new Date(parseInt(year), monthMap[month], parseInt(day));
|
||||
|
||||
history.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
rating: parseInt(ratingText),
|
||||
displayDate: dateText
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return history.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
module.exports = { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHistory };
|
||||
@@ -0,0 +1,317 @@
|
||||
const { parseDate } = require('../services/rating-calculator');
|
||||
|
||||
async function getOfficialRatingHistory(browser, pdgaNumber) {
|
||||
const page = await browser.newPage();
|
||||
let ratingHistory = [];
|
||||
|
||||
try {
|
||||
const url = `https://www.pdga.com/player/${pdgaNumber}/history`;
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
ratingHistory = await page.evaluate(() => {
|
||||
const history = [];
|
||||
|
||||
const selectors = [
|
||||
'table tbody tr',
|
||||
'table tr',
|
||||
'.view-content tbody tr'
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const rows = document.querySelectorAll(selector);
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length >= 3) {
|
||||
const dateText = cells[0]?.innerText?.trim();
|
||||
const ratingText = cells[1]?.innerText?.trim();
|
||||
|
||||
if (dateText && ratingText && /^\d{4}-\d{2}-\d{2}$|^\d{1,2}-\w{3}-\d{4}$|^\w{3} \d{1,2}, \d{4}$/.test(dateText)) {
|
||||
const rating = parseInt(ratingText);
|
||||
if (!isNaN(rating) && rating > 800 && rating < 1200) {
|
||||
history.push({
|
||||
date: dateText,
|
||||
rating: rating,
|
||||
tournament: cells[2]?.innerText?.trim() || 'Unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (history.length > 0) break;
|
||||
}
|
||||
|
||||
return history;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching official rating history:', error.message);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return ratingHistory;
|
||||
}
|
||||
|
||||
async function getPlayerTournamentDetails(browser, pdgaNumber) {
|
||||
const page = await browser.newPage();
|
||||
let tournamentRounds = [];
|
||||
|
||||
try {
|
||||
const url = `https://www.pdga.com/player/${pdgaNumber}/details`;
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
tournamentRounds = await page.evaluate(() => {
|
||||
const rounds = [];
|
||||
const rows = document.querySelectorAll('table tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td');
|
||||
|
||||
if (cells.length >= 4) {
|
||||
const cellTexts = Array.from(cells).map(cell => cell.innerText.trim());
|
||||
|
||||
let tournamentName = '';
|
||||
let dateText = '';
|
||||
let rating = 0;
|
||||
let division = '';
|
||||
|
||||
cellTexts.forEach((text, index) => {
|
||||
if (/\d{1,2}(-\w{3})?(\s+to\s+)\d{1,2}-\w{3}-\d{4}/.test(text) || /\d{1,2}-\w{3}-\d{4}/.test(text)) {
|
||||
dateText = text;
|
||||
}
|
||||
if (/^\d{3,4}$/.test(text) && parseInt(text) >= 800 && parseInt(text) <= 1200) {
|
||||
rating = parseInt(text);
|
||||
}
|
||||
if (/^M[A-Z]\d*$|^F[A-Z]\d*$/.test(text)) {
|
||||
division = text;
|
||||
}
|
||||
if (index === 0) {
|
||||
tournamentName = text;
|
||||
}
|
||||
});
|
||||
|
||||
if (tournamentName && dateText && rating > 0) {
|
||||
rounds.push({
|
||||
tournament: tournamentName,
|
||||
dateText: dateText,
|
||||
rating: rating,
|
||||
division: division,
|
||||
competition: `${tournamentName} (${division})`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rounds;
|
||||
});
|
||||
|
||||
const fixedRounds = tournamentRounds.map(round => {
|
||||
let validDate = new Date();
|
||||
if (round.dateText) {
|
||||
try {
|
||||
const pdgaParsed = parseDate(round.dateText);
|
||||
if (pdgaParsed instanceof Date && !isNaN(pdgaParsed.getTime())) {
|
||||
validDate = pdgaParsed;
|
||||
} else {
|
||||
const nativeParsed = new Date(round.dateText);
|
||||
if (!isNaN(nativeParsed.getTime())) {
|
||||
validDate = nativeParsed;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Date parsing failed for "${round.dateText}": ${e.message}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
tournament: round.tournament,
|
||||
date: validDate,
|
||||
rating: round.rating,
|
||||
division: round.division,
|
||||
competition: round.competition
|
||||
};
|
||||
});
|
||||
tournamentRounds = fixedRounds;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching tournament details:', error.message);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return tournamentRounds;
|
||||
}
|
||||
|
||||
async function getNewTournamentRounds(browser, pdgaNumber, afterDate) {
|
||||
const page = await browser.newPage();
|
||||
let newRounds = [];
|
||||
|
||||
try {
|
||||
const url = `https://www.pdga.com/player/${pdgaNumber}`;
|
||||
await page.goto(url, { waitUntil: 'networkidle2' });
|
||||
|
||||
console.log(`Looking for tournaments after ${afterDate.toDateString()}...`);
|
||||
|
||||
const newTournamentUrls = await page.evaluate((afterTimestamp) => {
|
||||
const afterDate = new Date(afterTimestamp);
|
||||
const tables = document.querySelectorAll('table[id*="player-results"]');
|
||||
const urls = [];
|
||||
|
||||
tables.forEach(table => {
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
rows.forEach(row => {
|
||||
const dateCell = row.querySelector('.dates');
|
||||
const tournamentCell = row.querySelector('.tournament a');
|
||||
|
||||
if (dateCell && tournamentCell) {
|
||||
const dateText = dateCell.innerText.trim();
|
||||
const dateMatch = dateText.match(/\d{1,2}-[A-Za-z]{3}-\d{4}/);
|
||||
|
||||
if (dateMatch) {
|
||||
const dateStr = dateMatch[0];
|
||||
const date = new Date(dateStr);
|
||||
|
||||
if (date > afterDate) {
|
||||
const href = tournamentCell.getAttribute('href');
|
||||
if (href) {
|
||||
urls.push({
|
||||
url: `https://www.pdga.com${href}`,
|
||||
date: dateStr,
|
||||
name: tournamentCell.innerText.trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return urls;
|
||||
}, afterDate.getTime());
|
||||
|
||||
console.log(`Found ${newTournamentUrls.length} new tournaments after ${afterDate.toDateString()}`);
|
||||
|
||||
for (const tournamentData of newTournamentUrls) {
|
||||
try {
|
||||
console.log(`Scraping new tournament: ${tournamentData.name} (${tournamentData.date})`);
|
||||
|
||||
await page.goto(tournamentData.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const roundRatings = await page.evaluate((pdgaNum) => {
|
||||
const rows = document.querySelectorAll('tr');
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = row.querySelectorAll('td');
|
||||
const hasPlayerNumber = Array.from(cells).some(cell =>
|
||||
cell.innerText && cell.innerText.includes(pdgaNum.toString())
|
||||
);
|
||||
|
||||
if (hasPlayerNumber) {
|
||||
const roundRatingCells = row.querySelectorAll('td.round-rating');
|
||||
const ratings = [];
|
||||
|
||||
roundRatingCells.forEach(cell => {
|
||||
const rating = parseInt(cell.innerText.trim());
|
||||
if (!isNaN(rating) && rating > 0) {
|
||||
ratings.push(rating);
|
||||
}
|
||||
});
|
||||
|
||||
return ratings;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}, pdgaNumber);
|
||||
|
||||
if (roundRatings.length > 0) {
|
||||
const parsedDate = parseDate(tournamentData.date);
|
||||
roundRatings.forEach(rating => {
|
||||
newRounds.push({
|
||||
rating,
|
||||
date: parsedDate,
|
||||
competition: tournamentData.name
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`✓ Found ${roundRatings.length} round ratings for ${tournamentData.name}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error scraping tournament ${tournamentData.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error getting new tournament rounds for PDGA ${pdgaNumber}:`, error);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return newRounds;
|
||||
}
|
||||
|
||||
async function getOptimizedPlayerRounds(browser, pdgaNumber) {
|
||||
console.log(`=== Optimized Round Collection for PDGA ${pdgaNumber} ===`);
|
||||
|
||||
try {
|
||||
console.log('Step 1: Getting official rating rounds from /details page...');
|
||||
const officialRounds = await getPlayerTournamentDetails(browser, pdgaNumber);
|
||||
|
||||
if (officialRounds.length === 0) {
|
||||
console.log('No official rounds found in details page');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`✓ Found ${officialRounds.length} official rating rounds`);
|
||||
|
||||
const sortedRounds = officialRounds.sort((a, b) => b.date - a.date);
|
||||
const latestOfficialDate = sortedRounds[0].date;
|
||||
console.log(`Latest official round: ${latestOfficialDate.toDateString()}`);
|
||||
|
||||
console.log('Step 2: Looking for NEW tournaments since latest official round...');
|
||||
const newRounds = await getNewTournamentRounds(browser, pdgaNumber, latestOfficialDate);
|
||||
|
||||
if (newRounds.length > 0) {
|
||||
console.log(`✓ Found ${newRounds.length} new round ratings`);
|
||||
} else {
|
||||
console.log('ℹ No new tournaments found since latest official round');
|
||||
}
|
||||
|
||||
const allRounds = [
|
||||
...officialRounds.map(round => ({
|
||||
rating: round.rating,
|
||||
date: round.date,
|
||||
competition: round.competition,
|
||||
source: 'official'
|
||||
})),
|
||||
...newRounds.map(round => ({
|
||||
rating: round.rating,
|
||||
date: round.date,
|
||||
competition: round.competition,
|
||||
source: 'new'
|
||||
}))
|
||||
];
|
||||
|
||||
allRounds.sort((a, b) => a.date - b.date);
|
||||
|
||||
console.log(`=== Summary: ${officialRounds.length} official + ${newRounds.length} new = ${allRounds.length} total rounds ===`);
|
||||
|
||||
return allRounds;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in optimized round collection:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOfficialRatingHistory,
|
||||
getPlayerTournamentDetails,
|
||||
getNewTournamentRounds,
|
||||
getOptimizedPlayerRounds
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
const { db } = require('../db');
|
||||
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB } = require('../models/player');
|
||||
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
|
||||
const { calculatePredictedRating } = require('./rating-calculator');
|
||||
|
||||
async function getPlayerDataFromDB(pdgaNumber) {
|
||||
try {
|
||||
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||
if (cachedPlayer) {
|
||||
console.log(`Loading PDGA ${pdgaNumber} from DB (source of truth)`);
|
||||
|
||||
let predictedRating = cachedPlayer.predicted_rating;
|
||||
let stdDev = cachedPlayer.std_dev;
|
||||
if (!predictedRating || predictedRating === 0) {
|
||||
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
|
||||
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||
stdDev = updatedPlayer?.std_dev;
|
||||
}
|
||||
|
||||
return {
|
||||
pdgaNumber: cachedPlayer.pdga_number,
|
||||
name: cachedPlayer.name,
|
||||
rating: cachedPlayer.current_rating,
|
||||
ratingChange: cachedPlayer.rating_change,
|
||||
predictedRating: predictedRating > 0 ? predictedRating : null,
|
||||
stdDev: stdDev > 0 ? stdDev : null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(`Database error for PDGA ${pdgaNumber}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapePDGARating(pdgaNumber, retries = 3) {
|
||||
console.log(`=== Refreshing PDGA ${pdgaNumber} from PDGA website ===`);
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
console.log(`Attempt ${attempt}/${retries} for PDGA ${pdgaNumber} (using HTTP)`);
|
||||
|
||||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||
const result = parsePlayerData(html, pdgaNumber);
|
||||
|
||||
try {
|
||||
await savePlayerToDB(result);
|
||||
console.log(`Saved PDGA ${pdgaNumber} to database`);
|
||||
} catch (dbErr) {
|
||||
console.error(`Failed to save PDGA ${pdgaNumber} to database:`, dbErr.message);
|
||||
}
|
||||
|
||||
console.log(`Successfully scraped PDGA ${pdgaNumber} on attempt ${attempt}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Attempt ${attempt}/${retries} failed for PDGA ${pdgaNumber}:`, error.message);
|
||||
|
||||
if (attempt === retries) {
|
||||
return {
|
||||
pdgaNumber,
|
||||
name: 'Error',
|
||||
rating: 0,
|
||||
ratingChange: null,
|
||||
predictedRating: null
|
||||
};
|
||||
}
|
||||
|
||||
let retryDelay = 2000 * attempt;
|
||||
|
||||
if (error.rateLimitInfo) {
|
||||
const retryAfter = error.rateLimitInfo.headers['retry-after'];
|
||||
if (retryAfter) {
|
||||
retryDelay = Math.max(retryDelay, (parseInt(retryAfter) + 1) * 1000);
|
||||
console.log(`Using Retry-After header: waiting ${retryDelay/1000}s`);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNRESET') {
|
||||
retryDelay = Math.max(retryDelay, 10000);
|
||||
console.log(`Connection reset detected: waiting ${retryDelay/1000}s`);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getPredictedRatingFromDB(pdgaNumber) {
|
||||
try {
|
||||
const roundHistory = await getRoundHistoryFromDB(pdgaNumber);
|
||||
if (roundHistory.length > 0) {
|
||||
console.log(`Using ${roundHistory.length} cached rounds for PDGA ${pdgaNumber} prediction`);
|
||||
|
||||
const roundRatings = roundHistory.map(round => ({
|
||||
rating: round.rating,
|
||||
date: new Date(round.date),
|
||||
competition: round.competition_name || 'Unknown'
|
||||
}));
|
||||
|
||||
const result = calculatePredictedRating(roundRatings);
|
||||
|
||||
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
|
||||
|
||||
return result.rating;
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(`Error getting predicted rating from DB for ${pdgaNumber}:`, err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllRatingsFromDB(progressCallback = null) {
|
||||
try {
|
||||
const allPlayers = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
'SELECT pdga_number, name, current_rating, rating_change FROM players ORDER BY pdga_number',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`Loading ${allPlayers.length} players from database...`);
|
||||
|
||||
const ratings = [];
|
||||
const total = allPlayers.length;
|
||||
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
const player = allPlayers[i];
|
||||
const pdgaNumber = player.pdga_number;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'loading'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const playerData = await getPlayerDataFromDB(pdgaNumber);
|
||||
|
||||
if (playerData) {
|
||||
ratings.push(playerData);
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'completed',
|
||||
name: playerData ? playerData.name : player.name
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load PDGA ${pdgaNumber} from database:`, error.message);
|
||||
const errorData = {
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
name: player.name || 'Database Error',
|
||||
rating: player.current_rating,
|
||||
ratingChange: player.rating_change,
|
||||
predictedRating: null
|
||||
};
|
||||
ratings.push(errorData);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'error',
|
||||
name: player.name || 'Database Error'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
|
||||
} catch (error) {
|
||||
console.error('Error loading players from database:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllPlayersInDB(progressCallback = null) {
|
||||
try {
|
||||
const allPlayers = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
'SELECT pdga_number, name FROM players ORDER BY pdga_number',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`Refreshing ${allPlayers.length} players from database...`);
|
||||
|
||||
const ratings = [];
|
||||
const total = allPlayers.length;
|
||||
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
const player = allPlayers[i];
|
||||
const pdgaNumber = player.pdga_number;
|
||||
|
||||
console.log(`Refreshing PDGA ${pdgaNumber}... (${i + 1}/${total})`);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'loading'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const playerData = await scrapePDGARating(pdgaNumber);
|
||||
ratings.push(playerData);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'completed',
|
||||
name: playerData.name
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh PDGA ${pdgaNumber}:`, error.message);
|
||||
const errorData = {
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
name: player.name || 'Error',
|
||||
rating: 0,
|
||||
ratingChange: null,
|
||||
predictedRating: null
|
||||
};
|
||||
ratings.push(errorData);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'error',
|
||||
name: player.name || 'Error'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
|
||||
} catch (error) {
|
||||
console.error('Error refreshing all players:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPlayerDataFromDB,
|
||||
scrapePDGARating,
|
||||
getPredictedRatingFromDB,
|
||||
getAllRatingsFromDB,
|
||||
refreshAllPlayersInDB
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
function parseDate(dateStr) {
|
||||
const multiDayMatch = dateStr.match(/^(\d{1,2})(-([A-Za-z]{3}))?(\s+to\s+)(\d{1,2})-([A-Za-z]{3})-(\d{4})$/);
|
||||
if (multiDayMatch) {
|
||||
const day = parseInt(multiDayMatch[1]);
|
||||
const month = multiDayMatch[3] || multiDayMatch[6];
|
||||
const year = parseInt(multiDayMatch[7]);
|
||||
|
||||
const monthMap = {
|
||||
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||||
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||||
};
|
||||
|
||||
return new Date(year, monthMap[month], day);
|
||||
}
|
||||
|
||||
const formats = [
|
||||
/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/,
|
||||
/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
|
||||
];
|
||||
|
||||
for (const format of formats) {
|
||||
const match = dateStr.match(format);
|
||||
if (match) {
|
||||
if (format === formats[0]) {
|
||||
const monthMap = {
|
||||
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||||
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||||
};
|
||||
const day = parseInt(match[1]);
|
||||
const month = monthMap[match[2]];
|
||||
const year = parseInt(match[3]);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
function getNextPDGAUpdateDate() {
|
||||
const today = new Date();
|
||||
const currentMonth = today.getMonth();
|
||||
const currentYear = today.getFullYear();
|
||||
|
||||
const firstDayOfMonth = new Date(currentYear, currentMonth, 1);
|
||||
const firstTuesday = new Date(firstDayOfMonth);
|
||||
|
||||
const daysUntilTuesday = (2 - firstDayOfMonth.getDay() + 7) % 7;
|
||||
firstTuesday.setDate(1 + daysUntilTuesday);
|
||||
|
||||
const secondTuesday = new Date(firstTuesday);
|
||||
secondTuesday.setDate(firstTuesday.getDate() + 7);
|
||||
|
||||
if (today <= secondTuesday) {
|
||||
return secondTuesday;
|
||||
} else {
|
||||
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
|
||||
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
|
||||
|
||||
const firstDayNextMonth = new Date(nextYear, nextMonth, 1);
|
||||
const firstTuesdayNext = new Date(firstDayNextMonth);
|
||||
|
||||
const daysUntilTuesdayNext = (2 - firstDayNextMonth.getDay() + 7) % 7;
|
||||
firstTuesdayNext.setDate(1 + daysUntilTuesdayNext);
|
||||
|
||||
const secondTuesdayNext = new Date(firstTuesdayNext);
|
||||
secondTuesdayNext.setDate(firstTuesdayNext.getDate() + 7);
|
||||
|
||||
return secondTuesdayNext;
|
||||
}
|
||||
}
|
||||
|
||||
function calculateStandardDeviation(ratings) {
|
||||
if (!ratings || ratings.length === 0) return 0;
|
||||
|
||||
const mean = ratings.reduce((sum, r) => sum + r, 0) / ratings.length;
|
||||
const variance = ratings.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / ratings.length;
|
||||
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
function calculatePredictedRating(roundRatings) {
|
||||
const debugLog = [];
|
||||
debugLog.push('=== PDGA RATING CALCULATION (Following Official Rules) ===');
|
||||
|
||||
if (!roundRatings || roundRatings.length === 0) {
|
||||
debugLog.push('❌ No rounds provided for prediction');
|
||||
return { rating: 0, debugLog };
|
||||
}
|
||||
|
||||
debugLog.push(`📊 Starting with ${roundRatings.length} total rounds`);
|
||||
|
||||
const nextUpdateDate = getNextPDGAUpdateDate();
|
||||
debugLog.push(`🎯 PDGA Update Simulation: Next update date is ${nextUpdateDate.toDateString()}`);
|
||||
debugLog.push(` Only including rounds played before ${nextUpdateDate.toDateString()}`);
|
||||
|
||||
const allSortedRounds = roundRatings
|
||||
.filter(r => r.rating > 0 && r.date < nextUpdateDate)
|
||||
.sort((a, b) => b.date - a.date);
|
||||
|
||||
if (allSortedRounds.length === 0) {
|
||||
debugLog.push('❌ No valid rounds after filtering for update date');
|
||||
return { rating: 0, debugLog };
|
||||
}
|
||||
|
||||
debugLog.push(`📊 After update date filter: ${allSortedRounds.length} rounds`);
|
||||
|
||||
const twelveMonthsBeforeUpdate = new Date(nextUpdateDate);
|
||||
twelveMonthsBeforeUpdate.setFullYear(twelveMonthsBeforeUpdate.getFullYear() - 1);
|
||||
|
||||
const mostRecentDate = allSortedRounds[0].date;
|
||||
debugLog.push(`📅 Most recent round: ${mostRecentDate.toDateString()}`);
|
||||
debugLog.push(`📅 12-month cutoff: ${twelveMonthsBeforeUpdate.toDateString()} (1 year before update)`);
|
||||
|
||||
let eligibleRounds = allSortedRounds.filter(r => r.date >= twelveMonthsBeforeUpdate);
|
||||
|
||||
debugLog.push('🗓️ 12-MONTH FILTERING:');
|
||||
debugLog.push(`✅ Rounds in last 12 months: ${eligibleRounds.length}`);
|
||||
|
||||
if (eligibleRounds.length < 8) {
|
||||
const twentyFourMonthsBeforeUpdate = new Date(nextUpdateDate);
|
||||
twentyFourMonthsBeforeUpdate.setFullYear(twentyFourMonthsBeforeUpdate.getFullYear() - 2);
|
||||
|
||||
eligibleRounds = allSortedRounds.filter(r => r.date >= twentyFourMonthsBeforeUpdate);
|
||||
debugLog.push(`⚠️ Extended to 24 months before update (${twentyFourMonthsBeforeUpdate.toDateString()}) - now ${eligibleRounds.length} rounds`);
|
||||
}
|
||||
|
||||
if (eligibleRounds.length === 0) {
|
||||
debugLog.push('❌ No eligible rounds found');
|
||||
return { rating: 0, debugLog };
|
||||
}
|
||||
|
||||
debugLog.push(`📈 ELIGIBLE ROUNDS: ${eligibleRounds.length}`);
|
||||
eligibleRounds.forEach((round, index) => {
|
||||
debugLog.push(` ${index + 1}. ${round.date.toDateString()}: ${round.rating} (${round.competition})`);
|
||||
});
|
||||
|
||||
let workingRounds = [...eligibleRounds];
|
||||
let workingRatings = workingRounds.map(r => r.rating);
|
||||
|
||||
if (workingRatings.length >= 7) {
|
||||
debugLog.push('🔍 OUTLIER EXCLUSION (≥7 rounds available):');
|
||||
|
||||
const mean = workingRatings.reduce((sum, r) => sum + r, 0) / workingRatings.length;
|
||||
const stdDev = calculateStandardDeviation(workingRatings);
|
||||
|
||||
debugLog.push(` Mean: ${mean.toFixed(1)}`);
|
||||
debugLog.push(` Std Dev: ${stdDev.toFixed(1)}`);
|
||||
|
||||
const stdDevCutoff = mean - 2.5 * stdDev;
|
||||
const hundredPointCutoff = mean - 100;
|
||||
|
||||
debugLog.push(` 2.5σ cutoff: ${stdDevCutoff.toFixed(1)}`);
|
||||
debugLog.push(` 100-point cutoff: ${hundredPointCutoff.toFixed(1)}`);
|
||||
|
||||
const filteredRatings = workingRatings.filter(rating =>
|
||||
rating >= stdDevCutoff && rating >= hundredPointCutoff
|
||||
);
|
||||
|
||||
const stdDevOutliers = workingRatings.filter(rating => rating < stdDevCutoff);
|
||||
const hundredPointOutliers = workingRatings.filter(rating => rating < hundredPointCutoff && rating >= stdDevCutoff);
|
||||
|
||||
if (stdDevOutliers.length > 0) {
|
||||
debugLog.push(` ❌ 2.5σ outliers removed: ${stdDevOutliers.length} rounds`);
|
||||
stdDevOutliers.forEach(rating => {
|
||||
const round = workingRounds.find(r => r.rating === rating);
|
||||
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (hundredPointOutliers.length > 0) {
|
||||
debugLog.push(` ❌ 100-point outliers removed: ${hundredPointOutliers.length} rounds`);
|
||||
hundredPointOutliers.forEach(rating => {
|
||||
const round = workingRounds.find(r => r.rating === rating);
|
||||
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (stdDevOutliers.length === 0 && hundredPointOutliers.length === 0) {
|
||||
debugLog.push(` ✅ No outliers detected`);
|
||||
}
|
||||
|
||||
if (filteredRatings.length >= 4) {
|
||||
workingRounds = workingRounds.filter(round =>
|
||||
round.rating >= stdDevCutoff && round.rating >= hundredPointCutoff
|
||||
);
|
||||
workingRatings = filteredRatings;
|
||||
debugLog.push(` ✅ Using ${filteredRatings.length} rounds after outlier removal`);
|
||||
} else {
|
||||
debugLog.push(` ⚠️ Too few rounds after outlier removal (${filteredRatings.length}), keeping all rounds`);
|
||||
}
|
||||
} else {
|
||||
debugLog.push(`⏭️ OUTLIER EXCLUSION SKIPPED (only ${workingRatings.length} rounds, need ≥7)`);
|
||||
}
|
||||
|
||||
debugLog.push('⚖️ WEIGHTING (Most recent 25% count double if ≥9 rounds):');
|
||||
const weightedRatings = [];
|
||||
|
||||
if (workingRatings.length >= 9) {
|
||||
const recentCount = Math.round(workingRatings.length * 0.25);
|
||||
debugLog.push(` ✅ Double-weighting most recent ${recentCount} rounds`);
|
||||
|
||||
weightedRatings.push(...workingRatings);
|
||||
|
||||
for (let i = 0; i < recentCount; i++) {
|
||||
weightedRatings.push(workingRatings[i]);
|
||||
const round = workingRounds[i];
|
||||
debugLog.push(` 2x weight: ${workingRatings[i]} (${round.date.toDateString()}: ${round.competition})`);
|
||||
}
|
||||
|
||||
debugLog.push(` 📊 Total values: ${workingRatings.length} + ${recentCount} double-weighted = ${weightedRatings.length}`);
|
||||
} else {
|
||||
debugLog.push(` ➡️ No double weighting (${workingRatings.length} rounds, need ≥9)`);
|
||||
weightedRatings.push(...workingRatings);
|
||||
}
|
||||
|
||||
const sum = weightedRatings.reduce((sum, r) => sum + r, 0);
|
||||
const average = sum / weightedRatings.length;
|
||||
const finalRating = Math.round(average);
|
||||
|
||||
const stdDev = calculateStandardDeviation(weightedRatings);
|
||||
|
||||
debugLog.push('🎯 FINAL CALCULATION:');
|
||||
debugLog.push(` Sum: ${sum}`);
|
||||
debugLog.push(` Count: ${weightedRatings.length}`);
|
||||
debugLog.push(` Average: ${average.toFixed(1)}`);
|
||||
debugLog.push(` Standard Deviation: ${stdDev.toFixed(1)}`);
|
||||
debugLog.push(` Final Rating: ${finalRating}`);
|
||||
debugLog.push('=== END PDGA CALCULATION ===');
|
||||
|
||||
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog };
|
||||
}
|
||||
|
||||
module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };
|
||||
@@ -0,0 +1,32 @@
|
||||
<% var body = `
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="course-search"
|
||||
name="q"
|
||||
placeholder="Search courses by name or city..."
|
||||
hx-get="/partials/course-table"
|
||||
hx-trigger="input changed delay:300ms, search"
|
||||
hx-target="#courses-table"
|
||||
/>
|
||||
</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" hx-get="/partials/course-table" hx-trigger="load"></div>
|
||||
`; %>
|
||||
|
||||
<%- include('../partials/layout', {
|
||||
title: 'PDGA Courses - Sweden',
|
||||
heading: 'PDGA Courses - Sweden',
|
||||
activePage: 'courses',
|
||||
cssFiles: ['courses.css'],
|
||||
jsFiles: ['courses.js'],
|
||||
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" hx-get="/partials/ratings-table" hx-trigger="load"></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: 'setupTooltipsAfterSwap();',
|
||||
body: body,
|
||||
modals: modals
|
||||
}) %>
|
||||
@@ -0,0 +1,71 @@
|
||||
<% if (layouts.length === 0) { %>
|
||||
<div class="no-layouts">No layouts found. Click the refresh icon to scrape layouts.</div>
|
||||
<% } else {
|
||||
var oneYearAgo = new Date();
|
||||
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
|
||||
|
||||
var activeLayouts = [];
|
||||
var inactiveLayouts = [];
|
||||
|
||||
layouts.forEach(function(layout) {
|
||||
if (layout.last_played) {
|
||||
var lastPlayedDate = new Date(layout.last_played);
|
||||
if (lastPlayedDate >= oneYearAgo) {
|
||||
activeLayouts.push(layout);
|
||||
} else {
|
||||
inactiveLayouts.push(layout);
|
||||
}
|
||||
} else {
|
||||
inactiveLayouts.push(layout);
|
||||
}
|
||||
});
|
||||
%>
|
||||
<h4 style="margin-top: 0;">Layouts:</h4>
|
||||
|
||||
<% if (activeLayouts.length > 0) { %>
|
||||
<% activeLayouts.forEach(function(layout) {
|
||||
var ratingDisplay = layout.mean_rating ?
|
||||
'<span style="color: #28a745; font-weight: bold; margin-left: 10px;">Rating: ' + layout.mean_rating + '</span>' : '';
|
||||
var 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>' : '';
|
||||
%>
|
||||
<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) { %>
|
||||
<div class="inactive-layouts-accordion">
|
||||
<div class="accordion-header" onclick="toggleAccordion('accordion-<%= courseId %>')">
|
||||
<span class="accordion-header-text">Inactive Layouts (<%= inactiveLayouts.length %>) - Not played in last year</span>
|
||||
<span class="accordion-icon" id="accordion-<%= courseId %>-icon">▼</span>
|
||||
</div>
|
||||
<div class="accordion-content" id="accordion-<%= courseId %>">
|
||||
<% inactiveLayouts.forEach(function(layout) {
|
||||
var ratingDisplay = layout.mean_rating ?
|
||||
'<span style="color: #28a745; font-weight: bold; margin-left: 10px;">Rating: ' + layout.mean_rating + '</span>' : '';
|
||||
var 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>';
|
||||
%>
|
||||
<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>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (activeLayouts.length === 0 && inactiveLayouts.length === 0) { %>
|
||||
<div class="no-layouts">No layouts found. Click the refresh icon to scrape layouts.</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
@@ -0,0 +1,46 @@
|
||||
<div id="search-results-info" class="search-results-info">
|
||||
<% if (typeof query !== 'undefined' && query) { %>
|
||||
Showing <%= courses.length %> of <%= total %> courses
|
||||
<% } else { %>
|
||||
Showing all <%= courses.length %> courses
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (courses.length === 0) { %>
|
||||
<p>No courses found. Click "Scrape Courses" to load Swedish courses from PDGA.</p>
|
||||
<% } else { %>
|
||||
<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(function(course) {
|
||||
var lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
%>
|
||||
<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>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!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">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<% if (history && history.length > 0) { %>
|
||||
<div class="chart-container" id="chart-<%= pdgaNumber %>"
|
||||
data-history="<%= JSON.stringify(history) %>"
|
||||
style="position: relative;">
|
||||
<div class="loading-chart">Loading chart...</div>
|
||||
</div>
|
||||
<div class="chart-tooltip" id="tooltip-<%= pdgaNumber %>"></div>
|
||||
<% } else { %>
|
||||
<div class="chart-container">
|
||||
<div class="loading-chart">No rating history available</div>
|
||||
</div>
|
||||
<% } %>
|
||||
@@ -0,0 +1,64 @@
|
||||
<% if (ratings.length === 0) { %>
|
||||
<p>No ratings found.</p>
|
||||
<% } else { %>
|
||||
<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(function(player, index) {
|
||||
var difference = player.predictedRating && player.rating ? player.predictedRating - player.rating : 0;
|
||||
var diffText = difference > 0 ? '+' + difference : difference.toString();
|
||||
var diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral';
|
||||
var ratingChangeText = player.ratingChange ? (player.ratingChange > 0 ? '+' + player.ratingChange : player.ratingChange.toString()) : 'N/A';
|
||||
var ratingChangeClass = player.ratingChange > 0 ? 'positive' : player.ratingChange < 0 ? 'negative' : 'neutral';
|
||||
%>
|
||||
<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 id="history-content-<%= player.pdgaNumber %>">
|
||||
<div class="loading-chart">Click to load rating history...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
Reference in New Issue
Block a user