Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddac06d68f | |||
| 5a5e45b685 | |||
| 77f00db037 | |||
| 50a60b29e7 | |||
| 15adddc2f1 | |||
| b6c674e4c7 | |||
| a7562e9b47 | |||
| 4b145094bf | |||
| 96ae7d7dac | |||
| 9feb5c2c43 | |||
| 88df98f269 | |||
| 259a3fadf1 | |||
| 7fb8cab5e2 | |||
| 7af9d8d69e | |||
| 16d375ae10 | |||
| 686d7ca00c | |||
| ac6008aa14 | |||
| cc223a4b8a | |||
| 0ded27f9df | |||
| 9df151f109 | |||
| b75e60da65 | |||
| e5f16e624e | |||
| 83ceaf0ea3 | |||
| b51ae19ae1 | |||
| 6129b6fd3b | |||
| 3dcd3131a0 | |||
| 19756b80e5 | |||
| 3f7a1bb7bf | |||
| 53bc6e571d | |||
| de99d4ede7 | |||
| 8c977d6624 | |||
| 6e05d3014d | |||
| b4206d9865 | |||
| 4a96d73fb9 | |||
| eb77b1f32b | |||
| 808619a04b | |||
| 86ca11c97e | |||
| 619567b550 | |||
| 59ee1f0b99 | |||
| e9a3c7f35e | |||
| 0c052dc7dd | |||
| d7f7bed8c6 | |||
| bdb8bca526 | |||
| 80616f6523 | |||
| a6250eb76a | |||
| 38cc93bc1c | |||
| 2ccb018bdf | |||
| d567c4bca9 | |||
| 0b55aeb632 | |||
| 4ac26dfb94 | |||
| 9bc71c7a37 | |||
| 1163337163 | |||
| c7ecd231ff | |||
| f0e68091c2 | |||
| 78cb2dc211 | |||
| 6ac32457a9 | |||
| 371a398446 | |||
| 2f73dddbd8 | |||
| bd33ac2901 | |||
| 7e5fa6cbf1 | |||
| 20bbdbbfcf | |||
| 33a962e6b8 | |||
| 10d1f88a58 | |||
| d46f045815 |
@@ -0,0 +1,37 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.shcizo.se
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: gitea.shcizo.se/shcizo/pdga-rating
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Deprecated: replaced by .gitea/workflows/docker-build.yml (tag-trigger).
|
||||
# Origin moved from GitHub to Gitea; release-please-action and ghcr.io are
|
||||
# GitHub-only and don't run on Gitea Actions. Trigger changed to manual-only
|
||||
# so neither GitHub nor Gitea Actions will auto-run this file.
|
||||
name: Release Please (deprecated)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "Deprecated. See .gitea/workflows/docker-build.yml"
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.cache/
|
||||
*.log
|
||||
*.log
|
||||
*.db
|
||||
@@ -0,0 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## [1.2.0](https://github.com/shcizo/pdga-rating/compare/v1.1.1...v1.2.0) (2026-03-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add Tjing course import ([eb77b1f](https://github.com/shcizo/pdga-rating/commit/eb77b1f32b8111f5e64c23ace4ecdfff92326a6a))
|
||||
|
||||
## [1.1.1](https://github.com/shcizo/pdga-rating/compare/v1.1.0...v1.1.1) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent course ID changes on re-scrape and add layout repair script ([619567b](https://github.com/shcizo/pdga-rating/commit/619567b550da10f6bbf682a4f482bf465d0d7a07))
|
||||
|
||||
## [1.1.0](https://github.com/shcizo/pdga-rating/compare/v1.0.0...v1.1.0) (2026-03-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add async tour system ([2ccb018](https://github.com/shcizo/pdga-rating/commit/2ccb018bdf9066940cb71120724015a58be45f3c))
|
||||
* allow custom layouts when creating tours ([38cc93b](https://github.com/shcizo/pdga-rating/commit/38cc93bc1c4ddb064ed29bb62b042de67a901e2e))
|
||||
* async tour system ([0c052dc](https://github.com/shcizo/pdga-rating/commit/0c052dc7dde9f7fdd0e7ea36a2ee4ffda263dcc4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* base tour points on total enrolled players, not submitted results ([bdb8bca](https://github.com/shcizo/pdga-rating/commit/bdb8bca526ffad4d4dc092fbcc86d6d5f3ab2d26))
|
||||
* move custom layout fields to own row in tour creation form ([a6250eb](https://github.com/shcizo/pdga-rating/commit/a6250eb76a6d8a6f939c50eac9f71b5508de1ec0))
|
||||
* upgrade Node 18 to 22 and fix Puppeteer compatibility ([d567c4b](https://github.com/shcizo/pdga-rating/commit/d567c4bca9cc5815d6aa259d0bf5ff38c3f1962c))
|
||||
* use fixed point scale for tour scoring ([d7f7bed](https://github.com/shcizo/pdga-rating/commit/d7f7bed8c667cd0db0d4e44e3e98f92ae49c3375))
|
||||
* use localStorage instead of sessionStorage for tour membership ([80616f6](https://github.com/shcizo/pdga-rating/commit/80616f6523a83a93500aaed742dc92694683a459))
|
||||
|
||||
## 1.0.0 (2026-02-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add Pino structured logging, release-please CI/CD and Docker pipeline ([6ac3245](https://github.com/shcizo/pdga-rating/commit/6ac32457a93d84a878c456200f45bb7ee2ef0785))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Point docker-compose to GHCR image instead of local build ([f0e6809](https://github.com/shcizo/pdga-rating/commit/f0e68091c211ae312ff2e704dc480cbfb7476b04))
|
||||
@@ -0,0 +1,57 @@
|
||||
# PDGA Rating Tracker
|
||||
|
||||
PDGA rating scraper and display app. Scrapes player ratings and course data from pdga.com, stores in SQLite, serves via Express with EJS templates and HTMX.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Node.js 18 (Alpine in Docker)
|
||||
- **Server:** Express with EJS templates
|
||||
- **Database:** SQLite3 (file: `ratings.db`, Docker: `/app/data/ratings.db`)
|
||||
- **Frontend:** HTMX + vanilla JS (in `public/js/`)
|
||||
- **Scraping:** Puppeteer (with stealth plugin) + direct HTTP
|
||||
- **Logging:** Pino (JSON in production, pino-pretty in dev)
|
||||
- **CI/CD:** Release Please + Docker build/push to GHCR
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
server.js # Express app entrypoint
|
||||
src/
|
||||
logger.js # Pino logger instance
|
||||
db.js # SQLite init, migrations, seeding
|
||||
models/ # Data access (player.js, course.js)
|
||||
routes/ # Express routes (players, courses, pages)
|
||||
scrapers/ # PDGA scrapers (HTTP + Puppeteer)
|
||||
services/ # Business logic (player-service, rating-calculator)
|
||||
views/
|
||||
pages/ # EJS page templates
|
||||
partials/ # EJS partials (shared layout)
|
||||
public/
|
||||
css/ # Stylesheets
|
||||
js/ # Client-side JS (HTMX interactions)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm start` — Start production server (port 3000)
|
||||
- `npm run dev` — Start with nodemon (auto-reload)
|
||||
- `LOG_LEVEL=debug npm start` — Enable debug logging
|
||||
- `docker compose up` — Run via Docker
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Logging:** Use `require('./logger')` (or relative path). Never use `console.log/error` in backend code. Use appropriate Pino levels: `debug` for verbose/diagnostic data, `info` for operational status, `warn` for retries/degraded state, `error` for failures, `fatal` for startup crashes.
|
||||
- **Frontend JS:** `console.error` is fine in `public/js/` — runs in browser, no Pino.
|
||||
- **Commits:** Conventional commits (`feat:`, `fix:`, `refactor:`, `chore:`) — drives release-please.
|
||||
- **Scraping:** Two strategies per entity: direct HTTP (fast, preferred) with Puppeteer fallback (stealth plugin for anti-bot). Rate limiting must be respected.
|
||||
- **Database:** Migrations run automatically on startup in `db.js`. Schema changes go there.
|
||||
- **Templates:** EJS with shared layout in `views/partials/`. Pages use HTMX for dynamic content loading.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_LEVEL` | `info` | Pino log level |
|
||||
| `NODE_ENV` | — | Set to `production` for JSON logs |
|
||||
| `DB_PATH` | `./ratings.db` | SQLite database path |
|
||||
| `PUPPETEER_EXECUTABLE_PATH` | — | Chromium path (set in Docker) |
|
||||
+10
-12
@@ -1,19 +1,16 @@
|
||||
# Use official Node.js runtime as base image
|
||||
FROM node:18-alpine
|
||||
FROM node:22-slim
|
||||
|
||||
# Install Chromium and dependencies for Puppeteer
|
||||
RUN apk add --no-cache \
|
||||
# Install Chromium from Debian repos (correct architecture for ARM/x86)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
freetype-dev \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont
|
||||
fonts-freefont-ttf \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Tell Puppeteer to skip installing Chromium. We'll be using the installed package.
|
||||
# Use system Chromium instead of Puppeteer's bundled download
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
@@ -31,10 +28,11 @@ COPY . .
|
||||
RUN mkdir -p data
|
||||
|
||||
# Set database path
|
||||
ENV DB_PATH=/app/data/ratings.db
|
||||
ENV DB_PATH=/app/data/ratings.db \
|
||||
NODE_ENV=production
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
CMD ["npm", "start"]
|
||||
|
||||
-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>
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pdga-ratings:
|
||||
build: .
|
||||
image: ghcr.io/shcizo/pdga-rating:latest
|
||||
container_name: pdga-ratings
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
-1300
File diff suppressed because it is too large
Load Diff
Generated
+1092
-130
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pdga-ratings",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.3",
|
||||
"description": "PDGA rating scraper and display",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,12 +8,17 @@
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"ejs": "^4.0.1",
|
||||
"express": "^4.18.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"puppeteer": "^21.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"puppeteer": "^24.40.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
"nodemon": "^3.0.1",
|
||||
"pino-pretty": "^13.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Courses Page
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Controls ─────────────────────────────────── */
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────────── */
|
||||
|
||||
.search-results-info {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Layouts ──────────────────────────────────── */
|
||||
|
||||
.layouts-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.layouts-container h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.layout-item {
|
||||
padding: 12px 14px;
|
||||
margin: 4px 0;
|
||||
background: var(--surface-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.layout-item:hover {
|
||||
border-color: var(--accent-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.layout-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-par {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-layouts {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Inactive Layouts Accordion ───────────────── */
|
||||
|
||||
.inactive-layouts-accordion {
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--surface-3);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.accordion-header-text {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.accordion-icon {
|
||||
transition: transform 0.3s ease;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.accordion-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.accordion-content.expanded {
|
||||
max-height: 2000px;
|
||||
padding: 12px;
|
||||
transition: max-height 0.5s ease-in;
|
||||
}
|
||||
|
||||
.layout-item.inactive {
|
||||
opacity: 0.6;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Players Page
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
/* ── (Add player now uses .btn-primary from shared.css) ── */
|
||||
|
||||
/* ── Mobile helpers ───────────────────────────── */
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Rating values ────────────────────────────── */
|
||||
|
||||
.rating-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pdga-number {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rating-change {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.predicted-value {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.difference {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Chart ────────────────────────────────────── */
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading-chart {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
position: fixed;
|
||||
background: var(--navy-900);
|
||||
color: var(--text-inverse);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* ── Tooltips ─────────────────────────────────── */
|
||||
|
||||
.std-dev-tooltip {
|
||||
position: absolute;
|
||||
background: var(--navy-900);
|
||||
color: var(--text-inverse);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Debug Icon ───────────────────────────────── */
|
||||
|
||||
.debug-icon:hover {
|
||||
opacity: 1 !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* ── Debug Modal ──────────────────────────────── */
|
||||
|
||||
.debug-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10001;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.debug-content {
|
||||
background: var(--surface-1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
max-width: 640px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-overlay);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.debug-header {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.debug-log {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-line;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.debug-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
font-size: 22px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition), background var(--transition);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.debug-close:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
/* ── Add Player Modal ─────────────────────────── */
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10001;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface-1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
box-shadow: var(--shadow-overlay);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
padding: 20px 24px;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
font-size: 24px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition), background var(--transition);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.btn-confirm:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chart-container {
|
||||
height: 250px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
function createRatingChart(container, history) {
|
||||
if (!history || history.length === 0) {
|
||||
container.textContent = '';
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'loading-chart';
|
||||
empty.textContent = 'No rating history available';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
var W = 880, H = 240;
|
||||
var pad = { left: 44, right: 16, top: 20, bottom: 32 };
|
||||
var chartW = W - pad.left - pad.right;
|
||||
var chartH = H - pad.top - pad.bottom;
|
||||
|
||||
var ratings = history.map(function(h) { return h.rating; });
|
||||
var minR = Math.min.apply(null, ratings) - 5;
|
||||
var maxR = Math.max.apply(null, ratings) + 5;
|
||||
var range = maxR - minR || 1;
|
||||
|
||||
function xOf(i) {
|
||||
return pad.left + (i / Math.max(history.length - 1, 1)) * chartW;
|
||||
}
|
||||
function yOf(r) {
|
||||
return pad.top + ((maxR - r) / range) * chartH;
|
||||
}
|
||||
|
||||
var pts = history.map(function(h, i) {
|
||||
return { x: xOf(i), y: yOf(h.rating), rating: h.rating, date: h.date };
|
||||
});
|
||||
|
||||
var linePath = pts.map(function(p, i) {
|
||||
return (i === 0 ? 'M' : 'L') + ' ' + p.x.toFixed(1) + ' ' + p.y.toFixed(1);
|
||||
}).join(' ');
|
||||
|
||||
var last = pts[pts.length - 1];
|
||||
var bottomY = (pad.top + chartH).toFixed(1);
|
||||
var areaPath = linePath +
|
||||
' L ' + last.x.toFixed(1) + ' ' + bottomY +
|
||||
' L ' + pad.left.toFixed(1) + ' ' + bottomY + ' Z';
|
||||
|
||||
// Build SVG using DOM API to avoid innerHTML on user-supplied content
|
||||
var ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function el(tag, attrs) {
|
||||
var e = document.createElementNS(ns, tag);
|
||||
Object.keys(attrs).forEach(function(k) { e.setAttribute(k, attrs[k]); });
|
||||
return e;
|
||||
}
|
||||
|
||||
function txt(tag, attrs, text) {
|
||||
var e = el(tag, attrs);
|
||||
e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
var svg = el('svg', {
|
||||
viewBox: '0 0 ' + W + ' ' + H,
|
||||
width: '100%',
|
||||
style: 'display:block;overflow:visible',
|
||||
'aria-hidden': 'true'
|
||||
});
|
||||
|
||||
// Grid lines + y-axis labels (4 ticks)
|
||||
var tickCount = 4;
|
||||
for (var i = 0; i <= tickCount; i++) {
|
||||
var gy = pad.top + (i / tickCount) * chartH;
|
||||
var gr = Math.round(maxR - (i / tickCount) * range);
|
||||
svg.appendChild(el('line', {
|
||||
x1: pad.left, y1: gy.toFixed(1),
|
||||
x2: pad.left + chartW, y2: gy.toFixed(1),
|
||||
stroke: 'var(--line)', 'stroke-width': '1', 'stroke-dasharray': '2 4'
|
||||
}));
|
||||
svg.appendChild(txt('text', {
|
||||
x: pad.left - 8, y: (gy + 4).toFixed(1),
|
||||
'text-anchor': 'end', 'font-size': '10',
|
||||
'font-family': "'JetBrains Mono', monospace",
|
||||
fill: 'var(--ink-3)'
|
||||
}, String(gr)));
|
||||
}
|
||||
|
||||
// Area fill (8% opacity)
|
||||
svg.appendChild(el('path', {
|
||||
d: areaPath, fill: 'var(--accent)', 'fill-opacity': '0.08'
|
||||
}));
|
||||
|
||||
// Line
|
||||
svg.appendChild(el('path', {
|
||||
d: linePath, stroke: 'var(--accent)', 'stroke-width': '2',
|
||||
fill: 'none', 'stroke-linejoin': 'round', 'stroke-linecap': 'round'
|
||||
}));
|
||||
|
||||
// Dots
|
||||
pts.forEach(function(p, i) {
|
||||
var isLast = i === pts.length - 1;
|
||||
if (isLast) {
|
||||
svg.appendChild(el('circle', {
|
||||
cx: p.x.toFixed(1), cy: p.y.toFixed(1),
|
||||
r: '4', fill: 'var(--accent)', stroke: 'var(--paper)', 'stroke-width': '2'
|
||||
}));
|
||||
} else {
|
||||
svg.appendChild(el('circle', {
|
||||
cx: p.x.toFixed(1), cy: p.y.toFixed(1),
|
||||
r: '3', fill: 'var(--accent)'
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// X-axis labels (5 evenly spaced)
|
||||
var labelCount = Math.min(5, history.length);
|
||||
var labelIndices = [];
|
||||
if (labelCount <= 1) {
|
||||
labelIndices.push(0);
|
||||
} else {
|
||||
for (var k = 0; k < labelCount; k++) {
|
||||
labelIndices.push(Math.round(k * (history.length - 1) / (labelCount - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
var seen = {};
|
||||
labelIndices.forEach(function(idx) {
|
||||
if (seen[idx]) return;
|
||||
seen[idx] = true;
|
||||
var p = pts[idx];
|
||||
var d = new Date(history[idx].date);
|
||||
var label = d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||
svg.appendChild(txt('text', {
|
||||
x: p.x.toFixed(1),
|
||||
y: (pad.top + chartH + 16).toFixed(1),
|
||||
'text-anchor': 'middle', 'font-size': '10',
|
||||
'font-family': "'JetBrains Mono', monospace",
|
||||
fill: 'var(--ink-3)'
|
||||
}, label));
|
||||
});
|
||||
|
||||
container.textContent = '';
|
||||
container.appendChild(svg);
|
||||
}
|
||||
@@ -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,535 @@
|
||||
const cachedDebugInfo = {};
|
||||
let pendingPlayerData = null;
|
||||
let openPdgaNumber = null;
|
||||
|
||||
// ── Delta-pill helper ─────────────────────────────
|
||||
function renderDeltaPill(value, extraClass) {
|
||||
const isNull = (value == null);
|
||||
const cls = isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat';
|
||||
const glyph = (isNull || value === 0) ? '–' : value > 0 ? '▲' : '▼';
|
||||
const num = isNull ? '—' : value > 0 ? '+' + value : String(value);
|
||||
return { glyph, num, cls };
|
||||
}
|
||||
|
||||
function applyDeltaPill(pillEl, value) {
|
||||
if (!pillEl) return;
|
||||
const pill = renderDeltaPill(value);
|
||||
pillEl.className = 'delta-pill ' + pill.cls;
|
||||
while (pillEl.firstChild) pillEl.removeChild(pillEl.firstChild);
|
||||
const glyphSpan = document.createElement('span');
|
||||
glyphSpan.className = 'delta-glyph';
|
||||
glyphSpan.textContent = pill.glyph;
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.className = 'delta-num';
|
||||
numSpan.textContent = pill.num;
|
||||
pillEl.appendChild(glyphSpan);
|
||||
pillEl.appendChild(numSpan);
|
||||
}
|
||||
|
||||
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('.player-chart, .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);
|
||||
const expandableRow = document.getElementById('row-' + pdgaNumber);
|
||||
|
||||
const isOpen = historyRow.style.display === 'table-row';
|
||||
|
||||
// Close any previously-open row
|
||||
if (openPdgaNumber !== null && openPdgaNumber !== pdgaNumber) {
|
||||
const prevHistory = document.getElementById('history-' + openPdgaNumber);
|
||||
const prevRow = document.getElementById('row-' + openPdgaNumber);
|
||||
if (prevHistory) {
|
||||
prevHistory.style.display = 'none';
|
||||
prevHistory.classList.remove('is-open');
|
||||
}
|
||||
if (prevRow) prevRow.classList.remove('row-open');
|
||||
openPdgaNumber = null;
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
historyRow.style.display = 'none';
|
||||
historyRow.classList.remove('is-open');
|
||||
expandableRow.classList.remove('row-open');
|
||||
openPdgaNumber = null;
|
||||
return;
|
||||
}
|
||||
|
||||
historyRow.style.display = 'table-row';
|
||||
// Force reflow so animation plays each open
|
||||
historyRow.classList.remove('is-open');
|
||||
void historyRow.offsetWidth;
|
||||
historyRow.classList.add('is-open');
|
||||
|
||||
expandableRow.classList.add('row-open');
|
||||
openPdgaNumber = pdgaNumber;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// Refreshes both the current rating and the prediction in one click, then
|
||||
// re-swaps the table so every derived value (deltas, pills, sparkline) reflects
|
||||
// the new state. Cheaper than fine-grained DOM updates and guaranteed consistent
|
||||
// because the server renders the truth.
|
||||
async function refreshPlayerData(pdgaNumber) {
|
||||
const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`);
|
||||
if (icon) icon.classList.add('spinning');
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }),
|
||||
fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' })
|
||||
]);
|
||||
htmx.ajax('GET', '/partials/ratings-table', { target: '#ratings-table', swap: 'innerHTML' });
|
||||
} catch (error) {
|
||||
console.error('Error refreshing player data:', error);
|
||||
} finally {
|
||||
if (icon) icon.classList.remove('spinning');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPlayer(pdgaNumber) {
|
||||
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('.cell-rating');
|
||||
|
||||
const nameLink = row.querySelector('.player-name a');
|
||||
if (nameLink) nameLink.textContent = data.player.name;
|
||||
|
||||
const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null;
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
|
||||
const deltaMonthPill = ratingCell ? ratingCell.querySelector('.delta-pill') : null;
|
||||
applyDeltaPill(deltaMonthPill, data.player.ratingChange);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing player:', error);
|
||||
alert('Failed to refresh player data');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRoundHistory(pdgaNumber) {
|
||||
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('.cell-rating');
|
||||
const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null;
|
||||
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) {
|
||||
// Rate-limited or scrape failure — common when refresh runs alongside the
|
||||
// current-rating refresh. Log but don't alert; the user-facing surface is
|
||||
// the spinner stopping (data may or may not have updated).
|
||||
console.error('Error refreshing round history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRatingHistory(pdgaNumber) {
|
||||
// No dedicated icon in the expanded row; spinner state not needed here
|
||||
const icon = null;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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(event) {
|
||||
if (event) event.preventDefault();
|
||||
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('.add-bar button[type="submit"]');
|
||||
const originalText = button ? button.textContent : '';
|
||||
if (button) { 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 {
|
||||
if (button) { 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;
|
||||
}
|
||||
|
||||
// ── Sparkline toggle ───────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const btn = document.getElementById('trendchart-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
const state = localStorage.getItem('ratingtracker.sparklines') || 'on';
|
||||
document.body.dataset.sparklines = state;
|
||||
btn.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false');
|
||||
|
||||
btn.addEventListener('click', function() {
|
||||
const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on';
|
||||
document.body.dataset.sparklines = next;
|
||||
btn.setAttribute('aria-pressed', next === 'on' ? 'true' : 'false');
|
||||
localStorage.setItem('ratingtracker.sparklines', next);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Expandable row keyboard support ───────────────
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const row = e.target;
|
||||
if (!row.classList || !row.classList.contains('expandable-row')) return;
|
||||
e.preventDefault();
|
||||
const pdgaNumber = row.id.replace('row-', '');
|
||||
togglePlayerHistory(parseInt(pdgaNumber, 10));
|
||||
});
|
||||
@@ -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,184 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const logger = require('./logger');
|
||||
|
||||
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) {
|
||||
logger.error('Error checking table schema:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
db.all("PRAGMA table_info(players)", (err, columns) => {
|
||||
if (err) {
|
||||
logger.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) {
|
||||
logger.info('Adding last_round_update column to players table...');
|
||||
db.run(`ALTER TABLE players ADD COLUMN last_round_update DATETIME DEFAULT NULL`, (err) => {
|
||||
if (err) logger.error('Error adding last_round_update column:', err.message);
|
||||
else logger.info('Successfully added last_round_update column');
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasPredictedRating) {
|
||||
logger.info('Adding predicted_rating column to players table...');
|
||||
db.run(`ALTER TABLE players ADD COLUMN predicted_rating INTEGER DEFAULT NULL`, (err) => {
|
||||
if (err) logger.error('Error adding predicted_rating column:', err.message);
|
||||
else logger.info('Successfully added predicted_rating column');
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasStdDev) {
|
||||
logger.info('Adding std_dev column to players table...');
|
||||
db.run(`ALTER TABLE players ADD COLUMN std_dev INTEGER DEFAULT NULL`, (err) => {
|
||||
if (err) logger.error('Error adding std_dev column:', err.message);
|
||||
else logger.info('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`, () => {
|
||||
logger.info('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) {
|
||||
logger.info(`✓ Database already has ${playerCount} players - skipping text file import`);
|
||||
logger.info('📝 Note: pdga-numbers.txt is only used when database is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('=== 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);
|
||||
|
||||
logger.info(`Found ${pdgaNumbers.length} PDGA numbers in file`);
|
||||
|
||||
if (pdgaNumbers.length === 0) {
|
||||
logger.info('⚠ No PDGA numbers found in file');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Populating database with players from file...');
|
||||
|
||||
for (let i = 0; i < pdgaNumbers.length; i++) {
|
||||
const pdgaNumber = pdgaNumbers[i];
|
||||
logger.info(`[${i + 1}/${pdgaNumbers.length}] Adding PDGA ${pdgaNumber}...`);
|
||||
|
||||
try {
|
||||
const playerData = await scrapePDGARating(pdgaNumber);
|
||||
logger.info(` ✓ Added ${playerData.name}`);
|
||||
|
||||
if (i < pdgaNumbers.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(` ✗ Failed to add PDGA ${pdgaNumber}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('=== Database population complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error during database population check:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { db, initializeDatabase, checkAndPopulateDatabase };
|
||||
@@ -0,0 +1,10 @@
|
||||
const pino = require('pino');
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty' }
|
||||
: undefined
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
@@ -0,0 +1,79 @@
|
||||
const { db } = require('../db');
|
||||
|
||||
function saveCourseToDB(courseData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
`INSERT INTO courses (name, link, city, last_updated)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(link) DO UPDATE SET name = excluded.name, city = excluded.city, last_updated = 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,282 @@
|
||||
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();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns monthly rating snapshots for one player (latest entry per calendar month),
|
||||
* ordered oldest → newest. At most `months` entries; [] if none.
|
||||
*/
|
||||
function getMonthlyHistory(pdgaNumber, months = 12) {
|
||||
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 rating
|
||||
FROM rating_history
|
||||
WHERE player_id = ?
|
||||
AND date IN (
|
||||
SELECT MAX(date)
|
||||
FROM rating_history
|
||||
WHERE player_id = ?
|
||||
GROUP BY strftime('%Y-%m', date)
|
||||
)
|
||||
ORDER BY date DESC
|
||||
LIMIT ?`,
|
||||
[player.id, player.id, months],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
resolve(rows.map(r => r.rating).reverse());
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the last `months` monthly rating snapshots for ALL players in one query.
|
||||
* Returns a Map<pdgaNumber, number[]> (oldest → newest per player).
|
||||
* Use this in bulk-fetch paths to avoid N+1 queries.
|
||||
*/
|
||||
function getAllMonthlyHistoriesFromDB(months = 12) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
`SELECT p.pdga_number, rh.date, rh.rating
|
||||
FROM rating_history rh
|
||||
JOIN players p ON rh.player_id = p.id
|
||||
INNER JOIN (
|
||||
SELECT player_id, MAX(date) AS max_date
|
||||
FROM rating_history
|
||||
GROUP BY player_id, strftime('%Y-%m', date)
|
||||
) latest ON rh.player_id = latest.player_id AND rh.date = latest.max_date
|
||||
ORDER BY p.pdga_number, rh.date ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.pdga_number)) map.set(row.pdga_number, []);
|
||||
map.get(row.pdga_number).push(row.rating);
|
||||
}
|
||||
// Trim each player's history to the requested window
|
||||
for (const [key, arr] of map) {
|
||||
if (arr.length > months) map.set(key, arr.slice(-months));
|
||||
}
|
||||
resolve(map);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getLastRefresh() {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(
|
||||
'SELECT MAX(last_updated) AS lastRefresh FROM players',
|
||||
[],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.lastRefresh : null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPlayerFromDB,
|
||||
savePlayerToDB,
|
||||
getRatingHistoryFromDB,
|
||||
saveRatingHistoryToDB,
|
||||
getRoundHistoryFromDB,
|
||||
getLastRoundUpdateDate,
|
||||
updateLastRoundUpdateDate,
|
||||
saveRoundHistoryToDB,
|
||||
savePredictedRatingToDB,
|
||||
getLastRefresh,
|
||||
getMonthlyHistory,
|
||||
getAllMonthlyHistoriesFromDB
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
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');
|
||||
const logger = require('../logger');
|
||||
|
||||
// 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) {
|
||||
logger.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) {
|
||||
logger.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) {
|
||||
logger.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 {
|
||||
logger.info('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) {
|
||||
logger.error({ err: error }, 'Error scraping courses');
|
||||
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)) {
|
||||
logger.info(`⚠️ 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');
|
||||
}
|
||||
|
||||
logger.info(`Starting layout scraping for course: ${course.name}`);
|
||||
|
||||
browser = await launchBrowser();
|
||||
|
||||
const layouts = await scrapeCourseLayouts(browser, course.link, courseId);
|
||||
|
||||
logger.info(`\n=== Starting event results scraping for ${course.name} ===`);
|
||||
|
||||
const courseIdInt = parseInt(courseId);
|
||||
const layoutData = layoutEventCache.get(courseIdInt);
|
||||
|
||||
if (!layoutData || layoutData.length === 0) {
|
||||
logger.info('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));
|
||||
}
|
||||
|
||||
logger.info(`\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
|
||||
);
|
||||
|
||||
logger.debug(`Layout: ${layoutDataResult.name} (Par ${layoutDataResult.par})`);
|
||||
logger.debug(` Total ratings collected: ${layoutDataResult.allRatings.length}`);
|
||||
logger.debug(` Mean rating: ${meanRating}`);
|
||||
logger.debug(` Last played: ${layoutDataResult.latestDate || 'Unknown'}`);
|
||||
|
||||
try {
|
||||
const changes = await updateLayoutRating(
|
||||
courseIdInt,
|
||||
layoutDataResult.name,
|
||||
layoutDataResult.par,
|
||||
meanRating,
|
||||
layoutDataResult.allRatings.length,
|
||||
layoutDataResult.latestDate
|
||||
);
|
||||
if (changes > 0) {
|
||||
logger.info(` ✓ Updated in database`);
|
||||
savedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.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) {
|
||||
logger.error({ err: error }, 'Error scraping layouts');
|
||||
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);
|
||||
logger.info(`✓ 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;
|
||||
|
||||
logger.info(`\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
|
||||
);
|
||||
|
||||
logger.debug(`Layout: ${ld.name} (Par ${ld.par})`);
|
||||
logger.debug(` Total ratings collected: ${ld.allRatings.length}`);
|
||||
logger.debug(` Mean rating: ${meanRating}`);
|
||||
logger.debug(` Last played: ${ld.latestDate || 'Unknown'}`);
|
||||
|
||||
try {
|
||||
const changes = await updateLayoutRating(
|
||||
courseIdInt,
|
||||
ld.name,
|
||||
ld.par,
|
||||
meanRating,
|
||||
ld.allRatings.length,
|
||||
ld.latestDate
|
||||
);
|
||||
if (changes > 0) {
|
||||
logger.info(` ✓ Updated in database`);
|
||||
savedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.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) {
|
||||
logger.error({ err: error }, 'Error scraping event results');
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch (e) {}
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to scrape event results' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getTopbarLocals } = require('../services/topbar-service');
|
||||
const { getAllRatingsFromDB, computeKpis } = require('../services/player-service');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const topbar = await getTopbarLocals();
|
||||
const players = await getAllRatingsFromDB();
|
||||
const kpis = computeKpis(players);
|
||||
res.render('index', { activePage: 'players', kpis, ...topbar });
|
||||
});
|
||||
|
||||
router.get('/courses', async (req, res) => {
|
||||
const topbar = await getTopbarLocals();
|
||||
res.render('courses', { activePage: 'courses', ...topbar });
|
||||
});
|
||||
|
||||
// Keep old URL working
|
||||
router.get('/courses.html', (req, res) => {
|
||||
res.redirect('/courses');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,447 @@
|
||||
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 { getTopbarLocals } = require('../services/topbar-service');
|
||||
const { calculatePredictedRating } = require('../services/rating-calculator');
|
||||
const logger = require('../logger');
|
||||
|
||||
let refreshInProgress = false;
|
||||
|
||||
router.post('/api/refresh-all', async (req, res, next) => {
|
||||
if (refreshInProgress) {
|
||||
logger.info('refresh-all already in progress, rejecting');
|
||||
return res.status(409).json({ error: 'Refresh already in progress' });
|
||||
}
|
||||
refreshInProgress = true;
|
||||
try {
|
||||
try {
|
||||
await refreshAllPlayersInDB();
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'refresh-all failed');
|
||||
}
|
||||
const page = req.body?.page === 'courses' ? 'courses' : 'players';
|
||||
const locals = await getTopbarLocals();
|
||||
res.render('../partials/topbar', { activePage: page, ...locals });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
} finally {
|
||||
refreshInProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
logger.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' })
|
||||
}));
|
||||
|
||||
const player = await getPlayerDataFromDB(pdgaNumber);
|
||||
res.render('../partials/player-history', { pdgaNumber, history: formattedHistory, player });
|
||||
} catch (error) {
|
||||
logger.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.get('/api/rating-history/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
|
||||
const cachedHistory = await getRatingHistoryFromDB(pdgaNumber);
|
||||
if (cachedHistory && cachedHistory.length > 0) {
|
||||
logger.info(`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;
|
||||
}
|
||||
|
||||
logger.info(`Fetching rating history for PDGA ${pdgaNumber}...`);
|
||||
const html = await fetchRatingHistory(pdgaNumber);
|
||||
const history = parseRatingHistory(html);
|
||||
|
||||
try {
|
||||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||||
logger.info(`Saved rating history for PDGA ${pdgaNumber} to database`);
|
||||
} catch (dbErr) {
|
||||
logger.error(`Failed to save rating history to database:`, dbErr.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
history
|
||||
});
|
||||
} catch (error) {
|
||||
logger.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) {
|
||||
logger.error('Error clearing database cache:', err);
|
||||
res.status(500).json({ error: 'Failed to clear database cache' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Database cache cleared - all players will be refreshed on next request');
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cache cleared - database reset'
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
logger.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;
|
||||
logger.info(`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) {
|
||||
logger.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' });
|
||||
}
|
||||
|
||||
logger.info(`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);
|
||||
|
||||
logger.info(`Successfully added player: ${playerData.name} (#${pdgaNumber})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
player: playerData
|
||||
});
|
||||
} catch (error) {
|
||||
logger.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;
|
||||
logger.info(`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) {
|
||||
logger.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;
|
||||
logger.info(`=== Manually refreshing rating history for PDGA ${pdgaNumber} ===`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const html = await fetchRatingHistory(pdgaNumber);
|
||||
const fetchTime = Date.now() - startTime;
|
||||
|
||||
logger.info(`HTML fetch completed in ${fetchTime}ms, received ${html.length} bytes`);
|
||||
|
||||
const parseStartTime = Date.now();
|
||||
const history = parseRatingHistory(html);
|
||||
const parseTime = Date.now() - parseStartTime;
|
||||
|
||||
logger.info(`Parsing completed in ${parseTime}ms, found ${history.length} history entries`);
|
||||
|
||||
if (history.length > 0) {
|
||||
logger.debug({ entries: history.slice(0, 3) }, 'Sample history entries');
|
||||
} else {
|
||||
logger.debug({ htmlSample: html.substring(0, 500) }, 'No history entries found');
|
||||
}
|
||||
|
||||
const dbStartTime = Date.now();
|
||||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||||
const dbTime = Date.now() - dbStartTime;
|
||||
|
||||
logger.info(`Database save completed in ${dbTime}ms`);
|
||||
|
||||
const formattedHistory = history.map(entry => ({
|
||||
date: entry.date,
|
||||
rating: entry.rating,
|
||||
displayDate: entry.displayDate
|
||||
}));
|
||||
|
||||
logger.info(`=== Rating history refresh completed for PDGA ${pdgaNumber} ===`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
history: formattedHistory
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`=== Error refreshing rating history for PDGA ${req.params.pdgaNumber} ===`);
|
||||
logger.error('Error type:', error.constructor.name);
|
||||
logger.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;
|
||||
|
||||
logger.info(`${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) {
|
||||
logger.error('Failed to fetch official history:', historyError.message);
|
||||
officialHistory = [];
|
||||
}
|
||||
|
||||
let allRounds = [];
|
||||
try {
|
||||
logger.info(`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);
|
||||
logger.info(`✓ Saved ${allRounds.length} rounds using optimized approach`);
|
||||
|
||||
await updateLastRoundUpdateDate(pdgaNumber);
|
||||
} else {
|
||||
logger.info('ℹ No rounds found');
|
||||
}
|
||||
} catch (detailsError) {
|
||||
logger.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) {
|
||||
logger.error(`=== Error refreshing round history for PDGA ${pdgaNumber} ===`);
|
||||
logger.error('Error type:', error.constructor.name);
|
||||
logger.error('Error message:', error.message);
|
||||
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (closeError) {
|
||||
logger.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: true,
|
||||
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,350 @@
|
||||
const { saveCourseToDB, saveLayoutToDB } = require('../models/course');
|
||||
const logger = require('../logger');
|
||||
|
||||
// In-memory cache for layout-division-event mapping
|
||||
const layoutEventCache = new Map();
|
||||
|
||||
function getLayoutEventCache() {
|
||||
return layoutEventCache;
|
||||
}
|
||||
|
||||
async function scrapeCourseDirectory(browser) {
|
||||
logger.info('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}`;
|
||||
|
||||
logger.info(`Scraping page ${pageNumber}...`);
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
await new Promise(r => setTimeout(r, 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) {
|
||||
logger.info(`No courses found on page ${pageNumber}, stopping pagination`);
|
||||
hasMorePages = false;
|
||||
} else {
|
||||
logger.info(`Found ${courses.length} courses on page ${pageNumber}`);
|
||||
allCourses.push(...courses);
|
||||
|
||||
for (const course of courses) {
|
||||
try {
|
||||
await saveCourseToDB(course);
|
||||
logger.info(`Saved course: ${course.name} (${course.city})`);
|
||||
} catch (err) {
|
||||
logger.error(`Error saving course ${course.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
|
||||
if (hasMorePages) {
|
||||
logger.info('Waiting 2s before next page...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Total courses scraped: ${allCourses.length} across ${pageNumber} pages`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error scraping course directory');
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return allCourses;
|
||||
}
|
||||
|
||||
async function scrapeCourseLayouts(browser, courseLink, courseId) {
|
||||
logger.info(`Scraping layouts from: ${courseLink}`);
|
||||
const page = await browser.newPage();
|
||||
const layouts = [];
|
||||
|
||||
try {
|
||||
await page.goto(courseLink, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||
await new Promise(r => setTimeout(r, 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) {
|
||||
logger.info('Layouts tab found and clicked');
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
} else {
|
||||
logger.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);
|
||||
|
||||
logger.info(`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) {
|
||||
logger.info(`Deduplicated to ${uniqueLayouts.length} unique layouts`);
|
||||
}
|
||||
|
||||
for (const layout of uniqueLayouts) {
|
||||
try {
|
||||
await saveLayoutToDB(courseId, layout);
|
||||
logger.info(`Saved layout: ${layout.name} (Par ${layout.par})`);
|
||||
} catch (err) {
|
||||
logger.error(`Error saving layout ${layout.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error scraping course layouts');
|
||||
} 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 new Promise(r => setTimeout(r, 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) {
|
||||
logger.error({ err: error }, 'Error scraping event results');
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return layoutRatings;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
layoutEventCache,
|
||||
getLayoutEventCache,
|
||||
scrapeCourseDirectory,
|
||||
scrapeCourseLayouts,
|
||||
scrapeEventResults
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
const https = require('https');
|
||||
const logger = require('../logger');
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
logger.info(`PDGA Response Status for #${pdgaNumber}: ${res.statusCode}`);
|
||||
|
||||
logger.debug({
|
||||
retryAfter: res.headers['retry-after'],
|
||||
rateLimit: res.headers['x-ratelimit-limit'],
|
||||
rateLimitRemaining: res.headers['x-ratelimit-remaining'],
|
||||
rateLimitReset: res.headers['x-ratelimit-reset']
|
||||
}, `Rate limit details for #${pdgaNumber}`);
|
||||
|
||||
const error = new Error(`HTTP ${res.statusCode}`);
|
||||
error.rateLimitInfo = rateLimitInfo;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
logger.error(`Request error for PDGA #${pdgaNumber}: ${error.code} ${error.message}`);
|
||||
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) {
|
||||
logger.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
|
||||
};
|
||||
|
||||
logger.info(`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) {
|
||||
logger.info(`Rating history request successful for PDGA #${pdgaNumber}`);
|
||||
resolve(data);
|
||||
} else {
|
||||
logger.error(`Rating History Error for PDGA #${pdgaNumber}:`);
|
||||
logger.error(`Status: ${res.statusCode}`);
|
||||
|
||||
logger.debug({
|
||||
retryAfter: res.headers['retry-after'],
|
||||
rateLimit: res.headers['x-ratelimit-limit'],
|
||||
rateLimitRemaining: res.headers['x-ratelimit-remaining']
|
||||
}, `Rate limit details for history #${pdgaNumber}`);
|
||||
|
||||
if (data.length > 0) {
|
||||
logger.debug(`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) => {
|
||||
logger.error({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
errno: error.errno,
|
||||
syscall: error.syscall
|
||||
}, `Rating history request error for PDGA #${pdgaNumber}`);
|
||||
|
||||
if (error.code === 'ECONNRESET') {
|
||||
logger.debug('Connection reset on rating history - likely rate limited by PDGA');
|
||||
}
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
logger.debug('Connection refused - PDGA server may be blocking requests');
|
||||
}
|
||||
if (error.code === 'ETIMEDOUT') {
|
||||
logger.debug('Request timed out - server may be overloaded');
|
||||
}
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
logger.info(`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,318 @@
|
||||
const { parseDate } = require('../services/rating-calculator');
|
||||
const logger = require('../logger');
|
||||
|
||||
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 new Promise(r => setTimeout(r, 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) {
|
||||
logger.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 new Promise(r => setTimeout(r, 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) {
|
||||
logger.info(`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) {
|
||||
logger.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' });
|
||||
|
||||
logger.info(`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());
|
||||
|
||||
logger.info(`Found ${newTournamentUrls.length} new tournaments after ${afterDate.toDateString()}`);
|
||||
|
||||
for (const tournamentData of newTournamentUrls) {
|
||||
try {
|
||||
logger.info(`Scraping new tournament: ${tournamentData.name} (${tournamentData.date})`);
|
||||
|
||||
await page.goto(tournamentData.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await new Promise(r => setTimeout(r, 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
|
||||
});
|
||||
});
|
||||
|
||||
logger.info(`Found ${roundRatings.length} round ratings for ${tournamentData.name}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Error scraping tournament ${tournamentData.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Error getting new tournament rounds for PDGA ${pdgaNumber}: ${error.message}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return newRounds;
|
||||
}
|
||||
|
||||
async function getOptimizedPlayerRounds(browser, pdgaNumber) {
|
||||
logger.info(`Optimized Round Collection for PDGA ${pdgaNumber}`);
|
||||
|
||||
try {
|
||||
logger.info('Getting official rating rounds from /details page...');
|
||||
const officialRounds = await getPlayerTournamentDetails(browser, pdgaNumber);
|
||||
|
||||
if (officialRounds.length === 0) {
|
||||
logger.info('No official rounds found in details page');
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.info(`Found ${officialRounds.length} official rating rounds`);
|
||||
|
||||
const sortedRounds = officialRounds.sort((a, b) => b.date - a.date);
|
||||
const latestOfficialDate = sortedRounds[0].date;
|
||||
logger.info(`Latest official round: ${latestOfficialDate.toDateString()}`);
|
||||
|
||||
logger.info('Looking for new tournaments since latest official round...');
|
||||
const newRounds = await getNewTournamentRounds(browser, pdgaNumber, latestOfficialDate);
|
||||
|
||||
if (newRounds.length > 0) {
|
||||
logger.info(`Found ${newRounds.length} new round ratings`);
|
||||
} else {
|
||||
logger.info('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);
|
||||
|
||||
logger.info(`Summary: ${officialRounds.length} official + ${newRounds.length} new = ${allRounds.length} total rounds`);
|
||||
|
||||
return allRounds;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error in optimized round collection: ' + error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOfficialRatingHistory,
|
||||
getPlayerTournamentDetails,
|
||||
getNewTournamentRounds,
|
||||
getOptimizedPlayerRounds
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
const { db } = require('../db');
|
||||
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB } = require('../models/player');
|
||||
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
|
||||
const { calculatePredictedRating } = require('./rating-calculator');
|
||||
const logger = require('../logger');
|
||||
|
||||
// Derives previous-month rating and the delta to it. Prefers PDGA's reported
|
||||
// rating_change (canonical), falls back to our own monthly snapshots when
|
||||
// rating_change is missing — common for players whose latest scrape failed.
|
||||
function deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory) {
|
||||
if (rating != null && rawRatingChange != null) {
|
||||
return { lastMonthRating: rating - rawRatingChange, ratingChange: rawRatingChange };
|
||||
}
|
||||
if (rating != null && monthlyHistory && monthlyHistory.length >= 1) {
|
||||
// The "last month" snapshot depends on whether current_rating is already in
|
||||
// history. If equal, current is the most recent entry — last month is the one
|
||||
// before it. If not, current is newer than history — the latest entry IS last month.
|
||||
const lastIdx = monthlyHistory.length - 1;
|
||||
const lastMonth = (monthlyHistory[lastIdx] === rating)
|
||||
? (monthlyHistory.length >= 2 ? monthlyHistory[lastIdx - 1] : null)
|
||||
: monthlyHistory[lastIdx];
|
||||
if (lastMonth != null) {
|
||||
return { lastMonthRating: lastMonth, ratingChange: rating - lastMonth };
|
||||
}
|
||||
}
|
||||
return { lastMonthRating: null, ratingChange: rawRatingChange };
|
||||
}
|
||||
|
||||
async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } = {}) {
|
||||
try {
|
||||
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||
if (cachedPlayer) {
|
||||
logger.debug(`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;
|
||||
}
|
||||
|
||||
const rating = cachedPlayer.current_rating;
|
||||
const rawRatingChange = cachedPlayer.rating_change;
|
||||
const resolvedPredicted = predictedRating > 0 ? predictedRating : null;
|
||||
const resolvedStdDev = stdDev > 0 ? stdDev : null;
|
||||
|
||||
// Skip in bulk-fetch paths where caller supplies history via getAllMonthlyHistoriesFromDB
|
||||
const monthlyHistory = includeMonthlyHistory
|
||||
? await getMonthlyHistory(cachedPlayer.pdga_number)
|
||||
: [];
|
||||
|
||||
const { lastMonthRating, ratingChange } = deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory);
|
||||
|
||||
return {
|
||||
pdgaNumber: cachedPlayer.pdga_number,
|
||||
name: cachedPlayer.name,
|
||||
rating,
|
||||
ratingChange,
|
||||
predictedRating: resolvedPredicted,
|
||||
stdDev: resolvedStdDev,
|
||||
lastMonthRating,
|
||||
// gap between next predicted update and current rating (null when either is missing)
|
||||
deltaPredicted: (resolvedPredicted != null && rating != null) ? resolvedPredicted - rating : null,
|
||||
monthlyHistory
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.error(`Database error for PDGA ${pdgaNumber}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapePDGARating(pdgaNumber, retries = 3) {
|
||||
logger.info(`Refreshing PDGA ${pdgaNumber} from PDGA website`);
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
logger.info(`Attempt ${attempt}/${retries} for PDGA ${pdgaNumber} (using HTTP)`);
|
||||
|
||||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||
const result = parsePlayerData(html, pdgaNumber);
|
||||
|
||||
try {
|
||||
await savePlayerToDB(result);
|
||||
logger.info(`Saved PDGA ${pdgaNumber} to database`);
|
||||
} catch (dbErr) {
|
||||
logger.error(`Failed to save PDGA ${pdgaNumber} to database:`, dbErr.message);
|
||||
}
|
||||
|
||||
logger.info(`Successfully scraped PDGA ${pdgaNumber} on attempt ${attempt}`);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
logger.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);
|
||||
logger.warn(`Using Retry-After header: waiting ${retryDelay/1000}s`);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNRESET') {
|
||||
retryDelay = Math.max(retryDelay, 10000);
|
||||
logger.warn(`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) {
|
||||
logger.debug(`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) {
|
||||
logger.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 || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
logger.info(`Loading ${allPlayers.length} players from database...`);
|
||||
|
||||
// Fetch all monthly histories in one query so the per-player loop doesn't add N extra queries
|
||||
const monthlyHistoryMap = await getAllMonthlyHistoriesFromDB(12);
|
||||
|
||||
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, { includeMonthlyHistory: false });
|
||||
|
||||
if (playerData) {
|
||||
playerData.monthlyHistory = monthlyHistoryMap.get(pdgaNumber) ?? [];
|
||||
// Re-derive now that history is attached — bulk path skipped includeMonthlyHistory
|
||||
const derived = deriveMonthlyDeltas(playerData.rating, player.rating_change, playerData.monthlyHistory);
|
||||
playerData.lastMonthRating = derived.lastMonthRating;
|
||||
playerData.ratingChange = derived.ratingChange;
|
||||
ratings.push(playerData);
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'completed',
|
||||
name: playerData ? playerData.name : player.name
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load PDGA ${pdgaNumber} from database:`, error.message);
|
||||
const errorRating = player.current_rating;
|
||||
const errorRatingChange = player.rating_change;
|
||||
const errorData = {
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
name: player.name || 'Database Error',
|
||||
rating: errorRating,
|
||||
ratingChange: errorRatingChange,
|
||||
predictedRating: null,
|
||||
stdDev: null,
|
||||
lastMonthRating: (errorRating != null && errorRatingChange != null) ? errorRating - errorRatingChange : null,
|
||||
deltaPredicted: null,
|
||||
monthlyHistory: []
|
||||
};
|
||||
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) {
|
||||
logger.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 || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
logger.info(`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;
|
||||
|
||||
logger.info(`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) {
|
||||
logger.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) {
|
||||
logger.error('Error refreshing all players:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates KPI summary stats from an already-fetched player array.
|
||||
* All fields are derived from the player list — no extra DB queries.
|
||||
*/
|
||||
function computeKpis(players) {
|
||||
const active = players.filter(p => p.rating != null && p.rating > 0);
|
||||
const avg = active.length > 0
|
||||
? Math.round(active.reduce((sum, p) => sum + p.rating, 0) / active.length)
|
||||
: null;
|
||||
|
||||
return {
|
||||
tracked: players.length,
|
||||
active: active.length,
|
||||
avg,
|
||||
climbing: players.filter(p => p.ratingChange != null && p.ratingChange > 0).length,
|
||||
slipping: players.filter(p => p.ratingChange != null && p.ratingChange < 0).length
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPlayerDataFromDB,
|
||||
scrapePDGARating,
|
||||
getPredictedRatingFromDB,
|
||||
getAllRatingsFromDB,
|
||||
refreshAllPlayersInDB,
|
||||
computeKpis
|
||||
};
|
||||
@@ -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,43 @@
|
||||
const { getLastRefresh } = require('../models/player');
|
||||
const logger = require('../logger');
|
||||
|
||||
function formatRelative(isoString) {
|
||||
if (!isoString) return 'Never';
|
||||
const then = new Date(isoString.replace(' ', 'T') + (isoString.endsWith('Z') ? '' : 'Z'));
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
if (Number.isNaN(diffMs) || diffMs < 0) return 'Just now';
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return 'Just now';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min} min ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr} h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day === 1) return 'Yesterday';
|
||||
if (day < 7) return `${day} days ago`;
|
||||
return then.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// First Tuesday of next month — approximation of PDGA's monthly cycle
|
||||
function computeNextUpdate(now = new Date()) {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth() + 1; // next month, may roll over
|
||||
const candidate = new Date(Date.UTC(month === 12 ? year + 1 : year, month === 12 ? 0 : month, 1));
|
||||
// 0=Sun, 1=Mon, 2=Tue
|
||||
const offset = (2 - candidate.getUTCDay() + 7) % 7;
|
||||
candidate.setUTCDate(1 + offset);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][candidate.getUTCDay()]} ${candidate.getUTCDate()} ${months[candidate.getUTCMonth()]}`;
|
||||
}
|
||||
|
||||
async function getTopbarLocals() {
|
||||
try {
|
||||
const lastIso = await getLastRefresh();
|
||||
return { lastRefresh: formatRelative(lastIso), nextUpdate: computeNextUpdate() };
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'topbar locals fallback');
|
||||
return { lastRefresh: 'Unknown', nextUpdate: computeNextUpdate() };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getTopbarLocals };
|
||||
@@ -0,0 +1,31 @@
|
||||
<% var body = `
|
||||
<div class="card-section">
|
||||
<h3>Find Courses</h3>
|
||||
<div class="card-section-form">
|
||||
<input
|
||||
type="text"
|
||||
class="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"
|
||||
style="width: 340px;"
|
||||
/>
|
||||
<button class="btn" onclick="scrapeCourses()" id="scrape-courses-btn">
|
||||
<i class="fas fa-sync-alt"></i> Scrape Courses
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="courses-table" hx-get="/partials/course-table" hx-trigger="load"></div>
|
||||
`; %>
|
||||
|
||||
<%- include('../partials/layout', {
|
||||
title: 'PDGA Courses - Sweden',
|
||||
activePage: 'courses',
|
||||
cssFiles: ['courses.css'],
|
||||
jsFiles: ['courses.js'],
|
||||
body: body
|
||||
}) %>
|
||||
@@ -0,0 +1,115 @@
|
||||
<% var body = `
|
||||
<!-- Add Player Card -->
|
||||
<form class="add-bar" onsubmit="searchAndAddPlayer(event)">
|
||||
<div class="add-bar-label">
|
||||
<span class="add-bar-kicker">Track a player</span>
|
||||
<span class="add-bar-hint">Add a PDGA number to start following their rating.</span>
|
||||
</div>
|
||||
<div class="add-bar-controls">
|
||||
<div class="input-wrap">
|
||||
<span class="input-prefix">#</span>
|
||||
<input
|
||||
type="text"
|
||||
id="pdga-number-input"
|
||||
inputmode="numeric"
|
||||
placeholder="277890"
|
||||
aria-label="PDGA number"
|
||||
oninput="this.value = this.value.replace(/[^0-9]/g, '')"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">
|
||||
<i class="fas fa-user-plus"></i> Add player
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- KPI Summary Tiles -->
|
||||
<section class="kpi-strip" aria-label="Tracker overview">
|
||||
<article class="kpi-tile">
|
||||
<span class="kpi-rail"></span>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-value">${kpis.tracked}</div>
|
||||
<div class="kpi-label">Tracked</div>
|
||||
<div class="kpi-sub">${kpis.active} active</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-tile">
|
||||
<span class="kpi-rail"></span>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-value">${kpis.avg ?? '—'}</div>
|
||||
<div class="kpi-label">Avg rating</div>
|
||||
<div class="kpi-sub">across active players</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-tile">
|
||||
<span class="kpi-rail up"></span>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-value">${kpis.climbing}</div>
|
||||
<div class="kpi-label">Climbing</div>
|
||||
<div class="kpi-sub">this month</div>
|
||||
</div>
|
||||
</article>
|
||||
<article class="kpi-tile">
|
||||
<span class="kpi-rail down"></span>
|
||||
<div class="kpi-body">
|
||||
<div class="kpi-value">${kpis.slipping}</div>
|
||||
<div class="kpi-label">Slipping</div>
|
||||
<div class="kpi-sub">this month</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Players Table Card -->
|
||||
<div class="table-card">
|
||||
<div class="table-toolbar">
|
||||
<span class="kicker">TRACKED PLAYERS</span>
|
||||
<div class="toolbar-actions">
|
||||
<button id="trendchart-toggle" class="pill-toggle" type="button" aria-pressed="false">
|
||||
<svg class="pill-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 17l5-6 4 4 4-7 5 6" />
|
||||
</svg>
|
||||
<span class="pill-label">Trend chart</span>
|
||||
<span class="pill-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ratings-table" hx-get="/partials/ratings-table" hx-trigger="load"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="footnote">Unofficial PDGA rating tracker. Ratings scraped from pdga.com on each refresh.</p>
|
||||
`; %>
|
||||
|
||||
<% 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',
|
||||
activePage: 'players',
|
||||
cssFiles: ['players.css'],
|
||||
jsFiles: ['tooltips.js', 'chart.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>Layouts:</h4>
|
||||
|
||||
<% if (activeLayouts.length > 0) { %>
|
||||
<% activeLayouts.forEach(function(layout) {
|
||||
var ratingDisplay = layout.mean_rating ?
|
||||
'<span style="color: var(--green); font-weight: 700; margin-left: 10px;">Rating: ' + layout.mean_rating + '</span>' : '';
|
||||
var dateDisplay = layout.last_played ?
|
||||
'<span style="color: var(--text-muted); 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: var(--green); font-weight: 700; margin-left: 10px;">Rating: ' + layout.mean_rating + '</span>' : '';
|
||||
var dateDisplay = layout.last_played ?
|
||||
'<span style="color: var(--text-muted); 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: var(--red); 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,12 @@
|
||||
<%/* delta-pill.ejs — renders a Δ-pill span.
|
||||
Locals:
|
||||
value {number|null} — the delta value (null/undefined → flat pill with '—')
|
||||
extraClass {string} — optional additional CSS class (e.g. 'delta-predicted-pill')
|
||||
*/%>
|
||||
<%
|
||||
const _isNull = (typeof value === 'undefined' || value == null);
|
||||
const _cls = _isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat';
|
||||
const _glyph = (_isNull || value === 0) ? '–' : value > 0 ? '▲' : '▼';
|
||||
const _num = _isNull ? '—' : value > 0 ? '+' + value : value.toString();
|
||||
const _xtra = (typeof extraClass !== 'undefined' && extraClass) ? ' ' + extraClass : '';
|
||||
%><span class="delta-pill <%= _cls %><%= _xtra %>"><span class="delta-glyph"><%= _glyph %></span><span class="delta-num"><%= _num %></span></span>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= title %></title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300..800;1,300..800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<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>
|
||||
<%- include('../partials/topbar', { activePage, lastRefresh, nextUpdate }) %>
|
||||
|
||||
<div class="container">
|
||||
<%- 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 @@
|
||||
<nav class="app-nav">
|
||||
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
|
||||
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
|
||||
</nav>
|
||||
@@ -0,0 +1,42 @@
|
||||
<%
|
||||
const hasPlayer = (typeof player !== 'undefined' && player);
|
||||
const chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber;
|
||||
%>
|
||||
<div class="player-detail">
|
||||
<% if (hasPlayer) { %>
|
||||
<div class="player-detail-left">
|
||||
<dl class="detail-grid">
|
||||
<div>
|
||||
<dt>Current rating</dt>
|
||||
<dd><%= player.rating ?? '—' %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last month</dt>
|
||||
<dd><%= player.lastMonthRating ?? '—' %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Change vs last month</dt>
|
||||
<dd><%- include('delta-pill', { value: player.ratingChange }) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Predicted next update</dt>
|
||||
<dd><%= player.predictedRating ?? '—' %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gap to predicted</dt>
|
||||
<dd><%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button class="link-btn" onclick="showDebugInfo(<%= player.pdgaNumber %>)" style="margin-top: 4px;">View calculation details →</button>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (history && history.length > 0) { %>
|
||||
<div class="player-chart" id="chart-<%= chartPdgaNumber %>"
|
||||
data-history="<%= JSON.stringify(history) %>">
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="loading-chart">No rating history available</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="chart-tooltip" id="tooltip-<%= chartPdgaNumber %>"></div>
|
||||
@@ -0,0 +1,100 @@
|
||||
<%
|
||||
function renderSparkline(values) {
|
||||
if (!values || values.length < 2) return '';
|
||||
var w = 96, h = 28;
|
||||
var min = Math.min.apply(null, values);
|
||||
var max = Math.max.apply(null, values);
|
||||
var range = max - min || 1;
|
||||
var xStep = w / (values.length - 1);
|
||||
|
||||
var pts = values.map(function(v, i) {
|
||||
return {
|
||||
x: (i * xStep).toFixed(1),
|
||||
y: (((max - v) / range) * (h - 4) + 2).toFixed(1)
|
||||
};
|
||||
});
|
||||
|
||||
var linePath = pts.map(function(p, i) {
|
||||
return (i === 0 ? 'M' : 'L') + ' ' + p.x + ' ' + p.y;
|
||||
}).join(' ');
|
||||
|
||||
var last = pts[pts.length - 1];
|
||||
var areaPath = linePath + ' L ' + last.x + ' ' + h + ' L 0 ' + h + ' Z';
|
||||
|
||||
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" class="spark" aria-hidden="true">' +
|
||||
'<path d="' + areaPath + '" style="fill:var(--accent);fill-opacity:0.10"/>' +
|
||||
'<path d="' + linePath + '" style="stroke:var(--accent);stroke-width:1.5;fill:none;stroke-linejoin:round;stroke-linecap:round"/>' +
|
||||
'<circle cx="' + last.x + '" cy="' + last.y + '" r="2.5" style="fill:var(--accent)"/>' +
|
||||
'</svg>';
|
||||
}
|
||||
%>
|
||||
<% if (ratings.length === 0) { %>
|
||||
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No players tracked yet.</p>
|
||||
<% } else { %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-rank">#</th>
|
||||
<th class="col-player">Player</th>
|
||||
<th class="col-rating">Rating<span class="th-hint">+ Δ since last update</span></th>
|
||||
<th class="col-predicted">Predicted<span class="th-hint">+ gap from today</span></th>
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% ratings.forEach(function(player, index) {
|
||||
const sparklineSvg = renderSparkline(player.monthlyHistory || []);
|
||||
%>
|
||||
<tr id="row-<%= player.pdgaNumber %>" class="expandable-row" tabindex="0" onclick="togglePlayerHistory(<%= player.pdgaNumber %>)">
|
||||
<td class="col-rank">
|
||||
<span class="rank-num mono"><%= index + 1 %></span>
|
||||
</td>
|
||||
<td class="col-player">
|
||||
<div class="cell-name">
|
||||
<span class="player-name"><a href="https://www.pdga.com/player/<%= player.pdgaNumber %>" target="_blank" onclick="event.stopPropagation()"><%= player.name %></a></span>
|
||||
<span class="pdga-num mono">#<%= player.pdgaNumber %></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-rating cell-rating">
|
||||
<% if (player.rating) { %>
|
||||
<div class="rating-stack">
|
||||
<span class="rating-value rating-big mono" data-rating="<%= player.rating || '' %>" data-stddev="<%= player.stdDev || '' %>" data-pdga="<%= player.pdgaNumber %>"><%= player.rating %></span>
|
||||
<%- include('delta-pill', { value: player.ratingChange }) %>
|
||||
<% if (sparklineSvg) { %><span class="sparkline"><%- sparklineSvg %></span><% } %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span class="rating-pending">Click to load</span>
|
||||
<% } %>
|
||||
<div class="std-dev-tooltip" id="tooltip-rating-<%= player.pdgaNumber %>"></div>
|
||||
</td>
|
||||
<td class="col-predicted" id="predicted-<%= player.pdgaNumber %>">
|
||||
<% if (player.predictedRating) { %>
|
||||
<div class="pred-stack">
|
||||
<span class="predicted-value pred-num mono" data-stddev="<%= player.stdDev || '' %>" data-pdga="<%= player.pdgaNumber %>"><%= player.predictedRating %></span>
|
||||
<%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span class="rating-pending">—</span>
|
||||
<% } %>
|
||||
<div class="std-dev-tooltip" id="tooltip-stddev-<%= player.pdgaNumber %>"></div>
|
||||
</td>
|
||||
<td class="col-actions cell-actions" onclick="event.stopPropagation()">
|
||||
<button class="icon-btn refresh-icon" onclick="refreshPlayerData(<%= player.pdgaNumber %>)" title="Refresh rating + prediction" aria-label="Refresh rating and prediction">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<button class="icon-btn icon-chev" onclick="togglePlayerHistory(<%= player.pdgaNumber %>)" title="Expand row" aria-label="Expand">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="history-<%= player.pdgaNumber %>" class="expanded-content">
|
||||
<td colspan="5" class="expanded-cell">
|
||||
<div id="history-content-<%= player.pdgaNumber %>">
|
||||
<div class="loading-chart">Click to load rating history...</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
@@ -0,0 +1,46 @@
|
||||
<header class="topbar" id="topbar">
|
||||
<div class="topbar__inner">
|
||||
<a href="/" class="topbar__brand">
|
||||
<span class="topbar__brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 17 L9 11 L13 15 L21 6" />
|
||||
<path d="M14 6 L21 6 L21 13" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="topbar__brand-text">
|
||||
<span class="topbar__brand-title">Rating Tracker</span>
|
||||
<span class="topbar__brand-sub">Disc golf · unofficial</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="topbar__nav" aria-label="Primary">
|
||||
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
|
||||
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
|
||||
</nav>
|
||||
|
||||
<div class="topbar__meta">
|
||||
<div class="topbar__meta-item">
|
||||
<span class="topbar__meta-label">Next update</span>
|
||||
<span class="topbar__meta-value"><%= nextUpdate %></span>
|
||||
</div>
|
||||
<div class="topbar__meta-item">
|
||||
<span class="topbar__meta-label">Last refresh</span>
|
||||
<span class="topbar__meta-value"><%= lastRefresh %></span>
|
||||
</div>
|
||||
<span class="topbar__divider" aria-hidden="true"></span>
|
||||
<button
|
||||
class="topbar__refresh"
|
||||
type="button"
|
||||
hx-post="/api/refresh-all"
|
||||
hx-vals='{"page": "<%= activePage %>"}'
|
||||
hx-target="#topbar"
|
||||
hx-swap="outerHTML"
|
||||
hx-disabled-elt="this"
|
||||
>
|
||||
<span class="topbar__refresh-icon" aria-hidden="true">↻</span>
|
||||
<span class="topbar__refresh-spinner" aria-hidden="true"></span>
|
||||
<span class="topbar__refresh-label">Refresh all</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
Reference in New Issue
Block a user