Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0040901ab | |||
| c0f9dd5f33 | |||
| 66e892893f | |||
| 5b9138da25 | |||
| b1e8d63a63 | |||
| c6ac174921 | |||
| fba1bea247 | |||
| a63da6f3ca | |||
| 3cfdc305ec | |||
| d336156bbb | |||
| e1b9e97484 | |||
| 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,42 @@
|
|||||||
|
name: Build and deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.shcizo.se
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
gitea.shcizo.se/shcizo/pdga-rating:${{ gitea.ref_name }}
|
||||||
|
gitea.shcizo.se/shcizo/pdga-rating:latest
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-and-push]
|
||||||
|
steps:
|
||||||
|
- uses: shcizo/package-updater-action@v1
|
||||||
|
with:
|
||||||
|
endpoint: https://updater.shcizo.se/update
|
||||||
|
image: gitea.shcizo.se/shcizo/pdga-rating
|
||||||
|
tag: ${{ gitea.ref_name }}
|
||||||
|
token: ${{ secrets.UPDATER_API_KEY }}
|
||||||
@@ -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/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.cache/
|
.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,68 @@
|
|||||||
|
# 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 22 (slim/Debian-based in Docker)
|
||||||
|
- **Hosting:** Gitea (`gitea.shcizo.se/shcizo/pdga-rating`) — use `tea` CLI for issues/PRs, not `gh`
|
||||||
|
- **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:** Gitea Actions (tag-triggered docker build/push to `gitea.shcizo.se/shcizo/pdga-rating`)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**No test framework or lint setup** — `package.json` has only `start` and `dev` scripts. If adding either, document it here.
|
||||||
|
|
||||||
|
## 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:`, `ci:`).
|
||||||
|
- **Releases:** Manual version bump — edit `version` in `package.json` + `package-lock.json`, commit as `<version>`, tag `v<version>`, push commit + tag (`git push origin main v<version>`). Triggers `.gitea/workflows/docker-build.yml` which builds and pushes the image. Auth uses repo secret `PACKAGES_TOKEN` (PAT with `write:package`) — the auto-injected `GITEA_TOKEN` does not have effective registry access.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## PDGA Domain Notes
|
||||||
|
|
||||||
|
- **Rating publication cycle:** PDGA officially recalculates ratings on the **second Tuesday of each month**. `getNextPDGAUpdateDate()` in `src/services/rating-calculator.js` computes this — round filtering uses it as cutoff.
|
||||||
|
- **Predicted rating algorithm:** `calculatePredictedRating(roundRatings)` replicates PDGA's formula — 12-mo window (expands to 24 if <8 rounds), outlier removal at ≥7 rounds (2.5σ + 100pt threshold), double-weighting of recent 25% at ≥9 rounds. Returns `{rating, stdDev, debugLog}`.
|
||||||
|
- **Rate limits:** `POST /api/refresh-round-history/:pdgaNumber` enforces a 24h cooldown per player (`src/routes/players.js`). Don't bypass — PDGA's site rate-limits aggressively.
|
||||||
|
- **Round history refresh** uses Puppeteer (stealth plugin), other scraping prefers direct HTTP. Predicted rating is recomputed and stored on each refresh.
|
||||||
|
|
||||||
|
## 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
|
# Use official Node.js runtime as base image
|
||||||
FROM node:18-alpine
|
FROM node:22-slim
|
||||||
|
|
||||||
# Install Chromium and dependencies for Puppeteer
|
# Install Chromium from Debian repos (correct architecture for ARM/x86)
|
||||||
RUN apk add --no-cache \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
chromium \
|
chromium \
|
||||||
nss \
|
|
||||||
freetype \
|
|
||||||
freetype-dev \
|
|
||||||
harfbuzz \
|
|
||||||
ca-certificates \
|
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 \
|
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -31,10 +28,11 @@ COPY . .
|
|||||||
RUN mkdir -p data
|
RUN mkdir -p data
|
||||||
|
|
||||||
# Set database path
|
# Set database path
|
||||||
ENV DB_PATH=/app/data/ratings.db
|
ENV DB_PATH=/app/data/ratings.db \
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start the application
|
# 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:
|
services:
|
||||||
pdga-ratings:
|
pdga-ratings:
|
||||||
build: .
|
build: .
|
||||||
|
image: ghcr.io/shcizo/pdga-rating:latest
|
||||||
container_name: pdga-ratings
|
container_name: pdga-ratings
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "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",
|
"name": "pdga-ratings",
|
||||||
"version": "1.0.0",
|
"version": "1.2.5",
|
||||||
"description": "PDGA rating scraper and display",
|
"description": "PDGA rating scraper and display",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,12 +8,17 @@
|
|||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ejs": "^4.0.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
"puppeteer": "^21.0.0",
|
"pino": "^10.3.1",
|
||||||
|
"puppeteer": "^24.40.0",
|
||||||
|
"puppeteer-extra": "^3.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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,542 @@
|
|||||||
|
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 initChartsIn(rootEl) {
|
||||||
|
rootEl.querySelectorAll('.player-chart').forEach(function(container) {
|
||||||
|
if (container.dataset.charted === 'true') return;
|
||||||
|
if (!container.dataset.history) return;
|
||||||
|
try {
|
||||||
|
const history = JSON.parse(container.dataset.history);
|
||||||
|
createRatingChart(container, history);
|
||||||
|
container.dataset.charted = 'true';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error rendering chart:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAfterTableSwap() {
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
const target = event.detail.target;
|
||||||
|
if (target.id === 'ratings-table') {
|
||||||
|
initRatingsTooltips();
|
||||||
|
initChartsIn(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.id && target.id.startsWith('history-content-')) {
|
||||||
|
initChartsIn(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,309 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the full rating history for ALL players in one query.
|
||||||
|
* Returns Map<pdgaNumber, {rating, date}[]> ordered chronologically (oldest → newest).
|
||||||
|
* Mirrors getAllMonthlyHistoriesFromDB but returns every point, not monthly snapshots.
|
||||||
|
*/
|
||||||
|
function getAllRatingHistoriesFromDB() {
|
||||||
|
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
|
||||||
|
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({ date: row.date, rating: row.rating });
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
getAllRatingHistoriesFromDB
|
||||||
|
};
|
||||||
@@ -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,445 @@
|
|||||||
|
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, formatDisplayDate } = 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>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Used only by the per-player "refresh rating history" button. The initial table render
|
||||||
|
// pre-attaches history via getAllRatingsFromDB to avoid the load-then-fetch race.
|
||||||
|
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: formatDisplayDate(row.date)
|
||||||
|
}));
|
||||||
|
|
||||||
|
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: formatDisplayDate(row.date)
|
||||||
|
}));
|
||||||
|
|
||||||
|
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,363 @@
|
|||||||
|
const { db } = require('../db');
|
||||||
|
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB, getAllRatingHistoriesFromDB } = require('../models/player');
|
||||||
|
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
|
||||||
|
const { calculatePredictedRating } = require('./rating-calculator');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
function formatDisplayDate(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
day: '2-digit', month: 'short', year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ratingHistoryMap = await getAllRatingHistoriesFromDB();
|
||||||
|
|
||||||
|
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) ?? [];
|
||||||
|
const rawHistory = ratingHistoryMap.get(pdgaNumber) ?? [];
|
||||||
|
playerData.ratingHistory = rawHistory.map(row => ({
|
||||||
|
date: row.date,
|
||||||
|
rating: row.rating,
|
||||||
|
displayDate: formatDisplayDate(row.date)
|
||||||
|
}));
|
||||||
|
// 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: [],
|
||||||
|
ratingHistory: []
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
formatDisplayDate
|
||||||
|
};
|
||||||
@@ -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,40 @@
|
|||||||
|
const { getLastRefresh } = require('../models/player');
|
||||||
|
const { getNextPDGAUpdateDate } = require('./rating-calculator');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNextUpdate(date) {
|
||||||
|
return `${DAY_NAMES[date.getDay()]} ${date.getDate()} ${MONTH_NAMES[date.getMonth()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTopbarLocals() {
|
||||||
|
const nextUpdate = formatNextUpdate(getNextPDGAUpdateDate());
|
||||||
|
try {
|
||||||
|
const lastIso = await getLastRefresh();
|
||||||
|
return { lastRefresh: formatRelative(lastIso), nextUpdate };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'topbar locals fallback');
|
||||||
|
return { lastRefresh: 'Unknown', nextUpdate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'setupAfterTableSwap();',
|
||||||
|
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,104 @@
|
|||||||
|
<%
|
||||||
|
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 %>" data-loaded="true">
|
||||||
|
<%- include('player-history', {
|
||||||
|
pdgaNumber: player.pdgaNumber,
|
||||||
|
history: player.ratingHistory || [],
|
||||||
|
player: player
|
||||||
|
}) %>
|
||||||
|
</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