Compare commits

..

60 Commits

Author SHA1 Message Date
Samuel Enocsson 15adddc2f1 re-swap table after refresh for consistent in-place updates
Release Please / release-please (push) Failing after 44s
Release Please / docker (push) Has been skipped
2026-05-21 16:11:32 +02:00
Samuel Enocsson b6c674e4c7 wire refresh button to update both rating and prediction 2026-05-21 16:04:15 +02:00
Samuel Enocsson a7562e9b47 fallback to monthlyHistory for lastMonthRating 2026-05-21 15:58:48 +02:00
Samuel Enocsson 4b145094bf render flat delta pill for null values per design spec 2026-05-21 15:51:17 +02:00
Samuel Enocsson 96ae7d7dac fix CSS for rating row 2026-05-21 15:33:21 +02:00
Samuel Enocsson 9feb5c2c43 fix: remove sticky thead — overlapped table toolbar (#4)
The thead had position: sticky; top: var(--topbar-height), pinning it
to 64px from the viewport. Inside the new .table-card with a toolbar
above, this pulled the header up out of its natural flow and overlapped
the toolbar and first data row. Let thead flow normally — design
doesn't require sticky behavior.
2026-05-21 15:27:08 +02:00
Samuel Enocsson 88df98f269 fix: place history chart in right grid column of expanded row (#7)
The dl + button + chart were 3 direct children of .player-detail (a
2-column grid). Auto-placement put the button in the right column,
forcing the chart to wrap to a second row in the left column.
Wrap dl + button in .player-detail-left so the chart occupies col 2.
2026-05-21 15:23:53 +02:00
Samuel Enocsson 259a3fadf1 chore: remove dead .refresh-section CSS (#4) 2026-05-21 15:17:09 +02:00
Samuel Enocsson 7fb8cab5e2 feat: add 'View calculation details' link to expanded row (#4) 2026-05-21 15:16:51 +02:00
Samuel Enocsson 7af9d8d69e fix: null-safe icon selectors after table restructure (#4) 2026-05-21 15:16:09 +02:00
Samuel Enocsson 16d375ae10 refactor: design-fidelity pass on players page 2026-05-21 15:15:29 +02:00
Samuel Enocsson 686d7ca00c fix: use template-literal interpolation for KPI strip inside body string (#5)
The page body is assembled as a JS template literal inside <% ... %>;
EJS tags inside that string break the EJS parser (it sees the first %>
as the close of the outer tag). Switch to ${kpis.x} interpolation since
we're already inside a backtick string.
2026-05-21 15:03:02 +02:00
Samuel Enocsson ac6008aa14 chore: delete dead progress.js (#4) 2026-05-21 14:38:06 +02:00
Samuel Enocsson cc223a4b8a fix: drop unused html field from renderDeltaPill (#3) 2026-05-21 14:38:06 +02:00
Samuel Enocsson 0ded27f9df fix: address code review findings — DRY delta-pill, var→const/let, tokenize colors 2026-05-21 14:38:06 +02:00
Samuel Enocsson 9df151f109 fix: return latest N months in getMonthlyHistory (#6) 2026-05-21 14:38:06 +02:00
Samuel Enocsson b75e60da65 feat: redesign expanded row with detail-grid + history chart (#7) 2026-05-21 14:38:06 +02:00
Samuel Enocsson e5f16e624e feat: pass player record into player-history partial render (#7) 2026-05-21 14:38:06 +02:00
Samuel Enocsson 83ceaf0ea3 feat: add KPI summary tiles above players table (#5) 2026-05-21 14:38:06 +02:00
Samuel Enocsson b51ae19ae1 feat: render sparklines + wire trend-chart pill toggle (#6) 2026-05-21 14:37:58 +02:00
Samuel Enocsson 6129b6fd3b feat: wire computeKpis into the page render (#5) 2026-05-21 14:37:48 +02:00
Samuel Enocsson 3dcd3131a0 feat: add monthlyHistory[] per player via getMonthlyHistory + bulk fetch (#6)
Add getMonthlyHistory() to models/player for single-player use and
getAllMonthlyHistoriesFromDB() for bulk fetches (one query, grouped in
memory). Wire monthlyHistory into all player objects returned by
getPlayerDataFromDB and getAllRatingsFromDB. Bulk path pre-fetches in
one query to avoid N extra per-player queries.
2026-05-21 13:41:20 +02:00
Samuel Enocsson 19756b80e5 feat: expose lastMonthRating and deltaPredicted on player objects (#3)
Add two derived fields to all player objects returned by
getPlayerDataFromDB and the error branch in getAllRatingsFromDB.
No new DB columns — both fields are pure arithmetic derivations.
monthlyHistory placeholder [] included ahead of A2 implementation.
2026-05-21 13:38:04 +02:00
Samuel Enocsson 3f7a1bb7bf chore: remove dead code orphaned by topbar redesign (#4)
The new topbar's "Refresh all" button replaces the old SSE-driven
"Load All" link and progress UI. With those gone, several pieces of
infrastructure had no callers left:

- GET /api/load-all-players, POST /api/populate-database, and
  GET /api/database-status — SSE endpoints with no frontend consumers
- #progress-section / #loading divs in players + courses pages
- .progress-container / .progress-bar / .progress-text / .loading CSS
- public/js/progress.js script (defines fetchRatingsWithProgress, never
  called, and loadAllPlayers, no longer wired) — to be deleted manually
  since the sandbox blocks rm
2026-05-21 13:15:53 +02:00
Samuel Enocsson 53bc6e571d chore: remove redundant "Load All" link from players page (#4)
The topbar's "Refresh all" button (introduced in #4) supersedes this
link. Leaving the gear icon for clearCache() — that's a separate
concern.
2026-05-21 13:11:28 +02:00
Samuel Enocsson de99d4ede7 fix: address code review for visual layer + topbar (#4) 2026-05-21 12:50:31 +02:00
Samuel Enocsson 8c977d6624 feat: shared visual layer + redesigned topbar (#4)
Introduce new design token set (paper/ink/line/accent + radius/shadow)
with backward-compat aliases for legacy --surface/--navy/--text names.
Swap DM Sans for Plus Jakarta Sans, add JetBrains Mono with tabular
numerics. Replace .app-header with sticky .topbar partial (brand +
segmented nav + Next update / Last refresh meta + Refresh all button).

Add POST /api/refresh-all that runs refreshAllPlayersInDB() with an
in-memory mutex and returns the rendered topbar so HTMX can swap it
in. "Next update" is computed as first Tuesday of next month
(approximation of PDGA's monthly cycle). "Last refresh" derives
from MAX(players.last_updated).
2026-05-21 12:37:31 +02:00
Samuel Enocsson 6e05d3014d refactor: remove tour feature and Tjing import
Release Please / release-please (push) Waiting to run
Release Please / docker (push) Blocked by required conditions
Tour functionality has moved to its own project (HyzrTour).
Removes all tour-related code, Tjing integration, and associated
views/styles/scripts. Keeps the saveCourseToDB ON CONFLICT fix.
2026-03-20 15:05:20 +01:00
shcizo b4206d9865 Merge pull request #7 from shcizo/release-please--branches--main--components--pdga-ratings
chore(main): release 1.2.0
2026-03-20 08:46:15 +01:00
github-actions[bot] 4a96d73fb9 chore(main): release 1.2.0 2026-03-20 07:45:38 +00:00
Samuel Enocsson eb77b1f32b feat: add Tjing course import
Search and import courses with layouts from Tjing's GraphQL API.
Total par is calculated from individual hole data. Courses are saved
with a tjing.se link as unique identifier to prevent duplicates.
2026-03-20 08:45:16 +01:00
shcizo 808619a04b Merge pull request #6 from shcizo/release-please--branches--main--components--pdga-ratings
chore(main): release 1.1.1
2026-03-20 08:11:44 +01:00
github-actions[bot] 86ca11c97e chore(main): release 1.1.1 2026-03-20 07:11:16 +00:00
Samuel Enocsson 619567b550 fix: prevent course ID changes on re-scrape and add layout repair script
saveCourseToDB now uses ON CONFLICT DO UPDATE instead of INSERT OR REPLACE,
which preserves the course ID and prevents orphaning of layout foreign keys.

Added scripts/repair-layouts.js to reassign orphaned layouts to their
correct courses by detecting the ID offset from re-scraping.
2026-03-20 08:11:01 +01:00
shcizo 59ee1f0b99 Merge pull request #4 from shcizo/release-please--branches--main--components--pdga-ratings
chore(main): release 1.1.0
2026-03-20 07:54:28 +01:00
github-actions[bot] e9a3c7f35e chore(main): release 1.1.0 2026-03-20 06:41:17 +00:00
shcizo 0c052dc7dd Merge pull request #5 from shcizo/feat/async-tours
feat: async tour system
2026-03-20 07:40:59 +01:00
Samuel Enocsson d7f7bed8c6 fix: use fixed point scale for tour scoring
1st=10, 2nd=8, 3rd=6, 4th=4, 5th=3, 6th=2, 7th=1, rest=0.
Ties get same points, next rank skips (1,1,3 pattern).
2026-03-20 07:39:43 +01:00
Samuel Enocsson bdb8bca526 fix: base tour points on total enrolled players, not submitted results
1st place now gets N points where N is total tour participants,
not just those who submitted for that specific course. This makes
the leaderboard meaningful even when not everyone has played yet.
2026-03-20 07:39:43 +01:00
Samuel Enocsson 80616f6523 fix: use localStorage instead of sessionStorage for tour membership
Persists across browser sessions so players don't need to rejoin.
2026-03-20 07:39:43 +01:00
Samuel Enocsson a6250eb76a fix: move custom layout fields to own row in tour creation form 2026-03-20 07:39:43 +01:00
Samuel Enocsson 38cc93bc1c feat: allow custom layouts when creating tours
Courses without scraped layouts can now be used in tours by entering
a layout name and par manually. The layout is saved to the database
for reuse. All courses are shown in the dropdown, not just those with
existing layouts.
2026-03-20 07:39:43 +01:00
Samuel Enocsson 2ccb018bdf feat: add async tour system
Players can create tours with selected courses/layouts and a date range.
Others join via a 6-character tour code, play the courses, and report
their total strokes. Live leaderboard with points and +/- par display.

Includes: database schema, model, service, routes, views, and styling.
2026-03-20 07:39:43 +01:00
Samuel Enocsson d567c4bca9 fix: upgrade Node 18 to 22 and fix Puppeteer compatibility
- Switch from Alpine to Debian slim for correct Chromium architecture
  (fixes ARM/Apple Silicon support)
- Upgrade Puppeteer 21 to 24, use system Chromium via PUPPETEER_EXECUTABLE_PATH
- Replace removed page.waitForTimeout() with setTimeout
- Set NODE_ENV=production in Dockerfile to prevent pino-pretty import
- Improve error logging with Pino's { err: error } pattern
- Add build: . to docker-compose for local development builds
2026-03-20 07:39:34 +01:00
shcizo 0b55aeb632 Merge pull request #3 from shcizo/release-please--branches--main--components--pdga-ratings
chore(main): release 1.0.0
2026-02-21 16:07:04 +01:00
github-actions[bot] 4ac26dfb94 chore(main): release 1.0.0 2026-02-21 15:06:19 +00:00
Samuel Enocsson 9bc71c7a37 chore: Re-trigger release-please 2026-02-21 16:05:57 +01:00
Samuel Enocsson 1163337163 chore: Trigger release-please after enabling PR permissions 2026-02-21 16:03:43 +01:00
Samuel Enocsson c7ecd231ff chore: Remove deprecated version key from docker-compose 2026-02-21 16:00:50 +01:00
Samuel Enocsson f0e68091c2 fix: Point docker-compose to GHCR image instead of local build 2026-02-21 16:00:31 +01:00
Samuel Enocsson 78cb2dc211 chore: Add CLAUDE.md project documentation 2026-02-21 15:57:48 +01:00
Samuel Enocsson 6ac32457a9 feat: Add Pino structured logging, release-please CI/CD and Docker pipeline
Replace all console.log/error with Pino logger (info/warn/error/debug/fatal)
for structured JSON logging in production and pretty-print in development.
Remove redundant header dumps and consolidate rate-limit logging.

Add GitHub Actions workflow with release-please for automated semver releases
and Docker build/push to GHCR on new releases.
2026-02-21 15:56:57 +01:00
Samuel Enocsson 371a398446 Modernize UI design with new color system, typography and layout
- Add sticky dark header with nav replacing inline text links
- Introduce CSS custom properties design system (colors, spacing, shadows)
- Use DM Sans + JetBrains Mono fonts replacing Arial
- Modernize tables with uppercase headers and subtle hover states
- Add gradient fill and rounded line to rating chart
- Unify card sections across players and courses pages
- Add backdrop blur to modals
- Clean up inline styles to use CSS variables
2026-02-19 09:12:04 +01:00
shcizo 2f73dddbd8 Merge pull request #2 from shcizo/refactor/modularize-htmx
Refactor: modularize server.js, add EJS/HTMX, stealth scraping
2026-02-19 09:01:13 +01:00
Samuel Enocsson bd33ac2901 Add puppeteer-extra stealth plugin to avoid headless browser detection
PDGA was blocking headless Chrome requests with ECONNRESET errors.
Using puppeteer-extra-plugin-stealth to mask headless browser
fingerprints (navigator.webdriver, chrome.runtime, plugins, etc).
2026-02-19 09:00:08 +01:00
Samuel Enocsson 7e5fa6cbf1 Add HTMX migration for server-rendered tables and lazy loading
- Add HTMX CDN to layout
- Replace client-side table rendering (displayRatings, displayCourses)
  with server-rendered EJS partials via hx-get
- Add server-side course search with debounced hx-trigger
- Lazy-load player history and course layouts via htmx.ajax()
- Render rating chart via htmx:afterSwap with data attributes
- Add partial routes: ratings-table, course-table, player-history,
  course-layouts
2026-02-19 08:29:56 +01:00
Samuel Enocsson 20bbdbbfcf Extract inline CSS/JS, add EJS templates with shared layout
- Extract CSS into public/css/{shared,players,courses}.css
- Extract JS into public/js/{chart,tooltips,progress,players,courses}.js
- Consolidate 5 duplicated tooltip blocks into setupTooltip() helper
- Add EJS view engine with layout partial and nav partial
- Convert HTML pages to EJS templates (index.ejs, courses.ejs)
- Add /courses route with redirect from /courses.html
- Remove old monolithic HTML files (1478 + 612 lines)
2026-02-18 22:32:03 +01:00
Samuel Enocsson 33a962e6b8 Refactor: split server.js monolith into modular architecture
Extract 3410-line server.js into 12 focused modules:
- src/db.js: database init and migrations
- src/models/{player,course}.js: DB helper functions
- src/scrapers/{browser,player-http,player-puppeteer,course-puppeteer}.js
- src/services/{player-service,rating-calculator}.js
- src/routes/{players,courses,pages}.js

Remove dead code: duplicate saveRatingHistoryToDB, legacy
getPlayerCompetitionRatings/getPredictedRating/getAllRatingsWithScraping,
unused getCourseFromDB/getLatestOfficialRoundDate/testPDGARateLimit,
legacy cache Map, and POST /api/predicted-rating route.

Consolidate 5 duplicated Puppeteer launch blocks into launchBrowser().
server.js is now 28 lines: imports, middleware, mount routers, bootstrap.
2026-02-18 22:20:58 +01:00
Samuel Enocsson 10d1f88a58 Add standard deviation display for predicted ratings
- Calculate and store standard deviation during rating prediction
- Add std_dev column to players database table
- Display standard deviation tooltip on hover over predicted rating
- Show rating range (±std_dev) tooltip on hover over current rating
- Update tooltips dynamically when ratings are refreshed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 17:48:21 +02:00
shcizo d46f045815 Merge pull request #1 from shcizo/feature/user-self-registration-and-rate-limits
Add user self-registration and implement rate limiting for predictions
2025-10-11 18:21:04 +02:00
44 changed files with 7012 additions and 5429 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Release Please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: node
docker:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.tag_name }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.tag_name }}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -1
View File
@@ -1,4 +1,5 @@
node_modules/
.env
.cache/
*.log
*.log
*.db
+3
View File
@@ -0,0 +1,3 @@
{
".": "1.2.0"
}
+45
View File
@@ -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))
+57
View File
@@ -0,0 +1,57 @@
# PDGA Rating Tracker
PDGA rating scraper and display app. Scrapes player ratings and course data from pdga.com, stores in SQLite, serves via Express with EJS templates and HTMX.
## Tech Stack
- **Runtime:** Node.js 18 (Alpine in Docker)
- **Server:** Express with EJS templates
- **Database:** SQLite3 (file: `ratings.db`, Docker: `/app/data/ratings.db`)
- **Frontend:** HTMX + vanilla JS (in `public/js/`)
- **Scraping:** Puppeteer (with stealth plugin) + direct HTTP
- **Logging:** Pino (JSON in production, pino-pretty in dev)
- **CI/CD:** Release Please + Docker build/push to GHCR
## Project Structure
```
server.js # Express app entrypoint
src/
logger.js # Pino logger instance
db.js # SQLite init, migrations, seeding
models/ # Data access (player.js, course.js)
routes/ # Express routes (players, courses, pages)
scrapers/ # PDGA scrapers (HTTP + Puppeteer)
services/ # Business logic (player-service, rating-calculator)
views/
pages/ # EJS page templates
partials/ # EJS partials (shared layout)
public/
css/ # Stylesheets
js/ # Client-side JS (HTMX interactions)
```
## Commands
- `npm start` — Start production server (port 3000)
- `npm run dev` — Start with nodemon (auto-reload)
- `LOG_LEVEL=debug npm start` — Enable debug logging
- `docker compose up` — Run via Docker
## Conventions
- **Logging:** Use `require('./logger')` (or relative path). Never use `console.log/error` in backend code. Use appropriate Pino levels: `debug` for verbose/diagnostic data, `info` for operational status, `warn` for retries/degraded state, `error` for failures, `fatal` for startup crashes.
- **Frontend JS:** `console.error` is fine in `public/js/` — runs in browser, no Pino.
- **Commits:** Conventional commits (`feat:`, `fix:`, `refactor:`, `chore:`) — drives release-please.
- **Scraping:** Two strategies per entity: direct HTTP (fast, preferred) with Puppeteer fallback (stealth plugin for anti-bot). Rate limiting must be respected.
- **Database:** Migrations run automatically on startup in `db.js`. Schema changes go there.
- **Templates:** EJS with shared layout in `views/partials/`. Pages use HTMX for dynamic content loading.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `LOG_LEVEL` | `info` | Pino log level |
| `NODE_ENV` | — | Set to `production` for JSON logs |
| `DB_PATH` | `./ratings.db` | SQLite database path |
| `PUPPETEER_EXECUTABLE_PATH` | — | Chromium path (set in Docker) |
+10 -12
View File
@@ -1,19 +1,16 @@
# Use official Node.js runtime as base image
FROM node:18-alpine
FROM node:22-slim
# Install Chromium and dependencies for Puppeteer
RUN apk add --no-cache \
# Install Chromium from Debian repos (correct architecture for ARM/x86)
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
nss \
freetype \
freetype-dev \
harfbuzz \
ca-certificates \
ttf-freefont
fonts-freefont-ttf \
&& rm -rf /var/lib/apt/lists/*
# Tell Puppeteer to skip installing Chromium. We'll be using the installed package.
# Use system Chromium instead of Puppeteer's bundled download
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# Set working directory
WORKDIR /app
@@ -31,10 +28,11 @@ COPY . .
RUN mkdir -p data
# Set database path
ENV DB_PATH=/app/data/ratings.db
ENV DB_PATH=/app/data/ratings.db \
NODE_ENV=production
# Expose port
EXPOSE 3000
# Start the application
CMD ["npm", "start"]
CMD ["npm", "start"]
-612
View File
@@ -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
View File
@@ -1,8 +1,7 @@
version: '3.8'
services:
pdga-ratings:
build: .
image: ghcr.io/shcizo/pdga-rating:latest
container_name: pdga-ratings
ports:
- "3000:3000"
-1300
View File
File diff suppressed because it is too large Load Diff
+1092 -130
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,6 +1,6 @@
{
"name": "pdga-ratings",
"version": "1.0.0",
"version": "1.2.0",
"description": "PDGA rating scraper and display",
"main": "server.js",
"scripts": {
@@ -8,12 +8,17 @@
"dev": "nodemon server.js"
},
"dependencies": {
"ejs": "^4.0.1",
"express": "^4.18.2",
"fs": "^0.0.1-security",
"puppeteer": "^21.0.0",
"pino": "^10.3.1",
"puppeteer": "^24.40.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"nodemon": "^3.0.1"
"nodemon": "^3.0.1",
"pino-pretty": "^13.1.3"
}
}
+134
View File
@@ -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;
}
+300
View File
@@ -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
+138
View File
@@ -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);
}
+87
View File
@@ -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');
}
}
+535
View File
@@ -0,0 +1,535 @@
const cachedDebugInfo = {};
let pendingPlayerData = null;
let openPdgaNumber = null;
// ── Delta-pill helper ─────────────────────────────
function renderDeltaPill(value, extraClass) {
const isNull = (value == null);
const cls = isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat';
const glyph = (isNull || value === 0) ? '' : value > 0 ? '▲' : '▼';
const num = isNull ? '—' : value > 0 ? '+' + value : String(value);
return { glyph, num, cls };
}
function applyDeltaPill(pillEl, value) {
if (!pillEl) return;
const pill = renderDeltaPill(value);
pillEl.className = 'delta-pill ' + pill.cls;
while (pillEl.firstChild) pillEl.removeChild(pillEl.firstChild);
const glyphSpan = document.createElement('span');
glyphSpan.className = 'delta-glyph';
glyphSpan.textContent = pill.glyph;
const numSpan = document.createElement('span');
numSpan.className = 'delta-num';
numSpan.textContent = pill.num;
pillEl.appendChild(glyphSpan);
pillEl.appendChild(numSpan);
}
function setupTooltipsAfterSwap() {
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'ratings-table') {
initRatingsTooltips();
}
// After player history partial loads, render the chart
const target = event.detail.target;
if (target.id && target.id.startsWith('history-content-')) {
const container = target.querySelector('.player-chart, .chart-container');
if (container && container.dataset.history) {
try {
const history = JSON.parse(container.dataset.history);
createRatingChart(container, history);
} catch (e) {
console.error('Error rendering chart:', e);
}
}
}
});
}
function initRatingsTooltips() {
document.querySelectorAll('.predicted-value').forEach(span => {
const pdgaNumber = span.dataset.pdga;
const stdDev = span.dataset.stddev;
const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`);
if (stdDev && tooltip) {
setupTooltip(span, tooltip, () => `Standard Deviation: \u00b1${stdDev}`);
}
});
document.querySelectorAll('.rating-value').forEach(span => {
const pdgaNumber = span.dataset.pdga;
const rating = parseInt(span.dataset.rating);
const stdDev = parseInt(span.dataset.stddev);
const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`);
if (rating && stdDev && tooltip) {
const minRating = rating - stdDev;
const maxRating = rating + stdDev;
setupTooltip(span, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`);
}
});
}
function togglePlayerHistory(pdgaNumber) {
const historyRow = document.getElementById('history-' + pdgaNumber);
const contentDiv = document.getElementById('history-content-' + pdgaNumber);
const expandableRow = document.getElementById('row-' + pdgaNumber);
const isOpen = historyRow.style.display === 'table-row';
// Close any previously-open row
if (openPdgaNumber !== null && openPdgaNumber !== pdgaNumber) {
const prevHistory = document.getElementById('history-' + openPdgaNumber);
const prevRow = document.getElementById('row-' + openPdgaNumber);
if (prevHistory) {
prevHistory.style.display = 'none';
prevHistory.classList.remove('is-open');
}
if (prevRow) prevRow.classList.remove('row-open');
openPdgaNumber = null;
}
if (isOpen) {
historyRow.style.display = 'none';
historyRow.classList.remove('is-open');
expandableRow.classList.remove('row-open');
openPdgaNumber = null;
return;
}
historyRow.style.display = 'table-row';
// Force reflow so animation plays each open
historyRow.classList.remove('is-open');
void historyRow.offsetWidth;
historyRow.classList.add('is-open');
expandableRow.classList.add('row-open');
openPdgaNumber = pdgaNumber;
if (contentDiv.dataset.loaded === 'true') {
return;
}
htmx.ajax('GET', '/partials/player-history/' + pdgaNumber, {target: '#history-content-' + pdgaNumber, swap: 'innerHTML'});
contentDiv.dataset.loaded = 'true';
}
async function clearCache() {
try {
const response = await fetch('/api/clear-cache', { method: 'POST' });
const data = await response.json();
if (data.success) {
alert(data.message);
if (confirm('Reload page to fetch fresh data?')) {
location.reload();
}
} else {
alert('Failed to clear cache');
}
} catch (error) {
console.error('Error clearing cache:', error);
alert('Error clearing cache');
}
}
// Refreshes both the current rating and the prediction in one click, then
// re-swaps the table so every derived value (deltas, pills, sparkline) reflects
// the new state. Cheaper than fine-grained DOM updates and guaranteed consistent
// because the server renders the truth.
async function refreshPlayerData(pdgaNumber) {
const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`);
if (icon) icon.classList.add('spinning');
try {
await Promise.allSettled([
fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }),
fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' })
]);
htmx.ajax('GET', '/partials/ratings-table', { target: '#ratings-table', swap: 'innerHTML' });
} catch (error) {
console.error('Error refreshing player data:', error);
} finally {
if (icon) icon.classList.remove('spinning');
}
}
async function refreshPlayer(pdgaNumber) {
try {
const response = await fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' });
const data = await response.json();
if (data.success) {
const row = document.getElementById(`row-${pdgaNumber}`);
const ratingCell = row.querySelector('.cell-rating');
const nameLink = row.querySelector('.player-name a');
if (nameLink) nameLink.textContent = data.player.name;
const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null;
if (ratingValue) {
ratingValue.textContent = data.player.rating || 'N/A';
ratingValue.dataset.rating = data.player.rating || '';
const stdDev = parseInt(ratingValue.dataset.stddev);
const rating = parseInt(data.player.rating);
const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`);
if (rating && stdDev && tooltip) {
const minRating = rating - stdDev;
const maxRating = rating + stdDev;
replaceWithTooltip(ratingValue, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`);
}
}
const deltaMonthPill = ratingCell ? ratingCell.querySelector('.delta-pill') : null;
applyDeltaPill(deltaMonthPill, data.player.ratingChange);
}
} catch (error) {
console.error('Error refreshing player:', error);
alert('Failed to refresh player data');
}
}
async function refreshRoundHistory(pdgaNumber) {
try {
const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' });
const data = await response.json();
if (!response.ok) {
throw new Error(JSON.stringify(data));
}
if (data.success) {
if (data.debugLog) {
cachedDebugInfo[pdgaNumber] = data.debugLog;
}
const predictedCell = document.getElementById(`predicted-${pdgaNumber}`);
if (predictedCell) {
const predictedValue = predictedCell.querySelector('.predicted-value');
if (predictedValue) {
predictedValue.textContent = data.predictedRating || 'N/A';
predictedValue.dataset.stddev = data.stdDev || '';
const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`);
if (data.stdDev && tooltip) {
replaceWithTooltip(predictedValue, tooltip, () => `Standard Deviation: \u00b1${data.stdDev}`);
}
}
}
const row = document.getElementById(`row-${pdgaNumber}`);
const ratingCell = row.querySelector('.cell-rating');
const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null;
if (ratingValue && data.stdDev) {
ratingValue.dataset.stddev = data.stdDev;
const rating = parseInt(ratingValue.dataset.rating);
const stdDev = parseInt(data.stdDev);
const ratingTooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`);
if (rating && stdDev && ratingTooltip) {
const minRating = rating - stdDev;
const maxRating = rating + stdDev;
replaceWithTooltip(ratingValue, ratingTooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`);
}
}
}
} catch (error) {
// Rate-limited or scrape failure — common when refresh runs alongside the
// current-rating refresh. Log but don't alert; the user-facing surface is
// the spinner stopping (data may or may not have updated).
console.error('Error refreshing round history:', error);
}
}
async function refreshRatingHistory(pdgaNumber) {
// No dedicated icon in the expanded row; spinner state not needed here
const icon = null;
try {
const response = await fetch(`/api/refresh-rating-history/${pdgaNumber}`, { method: 'POST' });
const data = await response.json();
if (data.success) {
const contentDiv = document.getElementById(`history-content-${pdgaNumber}`);
contentDiv.dataset.loaded = 'false';
htmx.ajax('GET', `/partials/player-history/${pdgaNumber}`, {target: `#history-content-${pdgaNumber}`, swap: 'innerHTML'});
contentDiv.dataset.loaded = 'true';
}
} catch (error) {
console.error('Error refreshing rating history:', error);
alert('Failed to refresh rating history');
}
}
async function showDebugInfo(pdgaNumber) {
const modal = document.getElementById('debug-modal');
const header = document.getElementById('debug-header');
const log = document.getElementById('debug-log');
const playerNameElement = document.querySelector(`#row-${pdgaNumber} .player-name a`);
const playerName = playerNameElement ? playerNameElement.textContent : `PDGA #${pdgaNumber}`;
header.textContent = `Prediction Calculation Details - ${playerName}`;
log.textContent = 'Loading calculation details...';
modal.style.display = 'flex';
try {
if (cachedDebugInfo[pdgaNumber]) {
log.textContent = cachedDebugInfo[pdgaNumber].join('\n');
return;
}
const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' });
const data = await response.json();
if (data.success && data.debugLog) {
cachedDebugInfo[pdgaNumber] = data.debugLog;
log.textContent = data.debugLog.join('\n');
} else {
log.textContent = 'No debug information available. Try refreshing the prediction first.';
}
} catch (error) {
console.error('Error fetching debug info:', error);
log.textContent = 'Error loading debug information. Please try again.';
}
}
function closeDebugModal(event) {
document.getElementById('debug-modal').style.display = 'none';
}
async function searchAndAddPlayer(event) {
if (event) event.preventDefault();
const input = document.getElementById('pdga-number-input');
const pdgaNumber = input.value.trim();
if (!pdgaNumber) {
alert('Please enter a PDGA number');
return;
}
const button = document.querySelector('.add-bar button[type="submit"]');
const originalText = button ? button.textContent : '';
if (button) { button.disabled = true; button.textContent = 'Searching...'; }
try {
const response = await fetch(`/api/search-player/${pdgaNumber}`);
const data = await response.json();
if (!response.ok) {
showErrorModal(data.error || 'Player not found');
return;
}
if (data.alreadyExists) {
showInfoModal(`${data.player.name} is already being tracked!`);
return;
}
pendingPlayerData = data.player;
showConfirmationModal(data.player);
} catch (error) {
console.error('Error searching for player:', error);
showErrorModal('Failed to search for player. Please try again.');
} finally {
if (button) { button.disabled = false; button.textContent = originalText; }
}
}
function showConfirmationModal(player) {
const modal = document.getElementById('add-player-modal');
document.getElementById('add-player-modal-header').textContent = 'Confirm Player';
const body = document.getElementById('add-player-modal-body');
body.textContent = '';
const question = document.createElement('p');
question.textContent = 'Is this the correct player you want to add?';
body.appendChild(question);
const info = document.createElement('div');
info.style.cssText = 'background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin-top: 15px;';
const name = document.createElement('strong');
name.style.cssText = 'font-size: 18px; color: #007bff;';
name.textContent = player.name;
info.appendChild(name);
info.appendChild(document.createElement('br'));
const pdga = document.createElement('span');
pdga.style.color = '#6c757d';
pdga.textContent = `PDGA #${player.pdgaNumber}`;
info.appendChild(pdga);
info.appendChild(document.createElement('br'));
const rating = document.createElement('span');
if (player.rating) {
rating.style.cssText = 'color: #28a745; font-weight: bold;';
rating.textContent = `Current Rating: ${player.rating}`;
} else {
rating.style.color = '#999';
rating.textContent = 'No rating available';
}
info.appendChild(rating);
body.appendChild(info);
const footer = document.getElementById('add-player-modal-footer');
footer.textContent = '';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = closeAddPlayerModal;
footer.appendChild(cancelBtn);
const confirmBtn = document.createElement('button');
confirmBtn.className = 'btn btn-confirm';
confirmBtn.textContent = 'Add Player';
confirmBtn.onclick = confirmAddPlayer;
footer.appendChild(confirmBtn);
modal.style.display = 'flex';
}
function showErrorModal(message) {
const modal = document.getElementById('add-player-modal');
document.getElementById('add-player-modal-header').textContent = 'Player Not Found';
const body = document.getElementById('add-player-modal-body');
body.textContent = '';
const errorP = document.createElement('p');
errorP.style.color = '#dc3545';
errorP.textContent = message;
body.appendChild(errorP);
const helpP = document.createElement('p');
helpP.style.cssText = 'margin-top: 10px; color: #6c757d; font-size: 14px;';
helpP.textContent = 'Please check the PDGA number and try again.';
body.appendChild(helpP);
const footer = document.getElementById('add-player-modal-footer');
footer.textContent = '';
const closeBtn = document.createElement('button');
closeBtn.className = 'btn btn-cancel';
closeBtn.textContent = 'Close';
closeBtn.onclick = closeAddPlayerModal;
footer.appendChild(closeBtn);
modal.style.display = 'flex';
}
function showInfoModal(message) {
const modal = document.getElementById('add-player-modal');
document.getElementById('add-player-modal-header').textContent = 'Information';
const body = document.getElementById('add-player-modal-body');
body.textContent = '';
const infoP = document.createElement('p');
infoP.style.color = '#007bff';
infoP.textContent = message;
body.appendChild(infoP);
const footer = document.getElementById('add-player-modal-footer');
footer.textContent = '';
const closeBtn = document.createElement('button');
closeBtn.className = 'btn btn-cancel';
closeBtn.textContent = 'Close';
closeBtn.onclick = closeAddPlayerModal;
footer.appendChild(closeBtn);
modal.style.display = 'flex';
}
async function confirmAddPlayer() {
if (!pendingPlayerData) {
closeAddPlayerModal();
return;
}
const body = document.getElementById('add-player-modal-body');
body.textContent = 'Adding player...';
document.getElementById('add-player-modal-footer').textContent = '';
try {
const response = await fetch('/api/add-player', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pdgaNumber: pendingPlayerData.pdgaNumber })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to add player');
}
body.textContent = '';
const successP = document.createElement('p');
successP.style.cssText = 'color: #28a745; text-align: center;';
successP.textContent = `${data.player.name} has been added successfully!`;
body.appendChild(successP);
const footer = document.getElementById('add-player-modal-footer');
footer.textContent = '';
const okBtn = document.createElement('button');
okBtn.className = 'btn btn-confirm';
okBtn.textContent = 'OK';
okBtn.onclick = function() { closeAddPlayerModal(); location.reload(); };
footer.appendChild(okBtn);
document.getElementById('pdga-number-input').value = '';
pendingPlayerData = null;
} catch (error) {
console.error('Error adding player:', error);
body.textContent = error.message;
body.style.color = '#dc3545';
const footer = document.getElementById('add-player-modal-footer');
footer.textContent = '';
const closeBtn = document.createElement('button');
closeBtn.className = 'btn btn-cancel';
closeBtn.textContent = 'Close';
closeBtn.onclick = closeAddPlayerModal;
footer.appendChild(closeBtn);
}
}
function closeAddPlayerModal(event) {
document.getElementById('add-player-modal').style.display = 'none';
pendingPlayerData = null;
}
// ── Sparkline toggle ───────────────────────────────
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('trendchart-toggle');
if (!btn) return;
const state = localStorage.getItem('ratingtracker.sparklines') || 'on';
document.body.dataset.sparklines = state;
btn.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false');
btn.addEventListener('click', function() {
const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on';
document.body.dataset.sparklines = next;
btn.setAttribute('aria-pressed', next === 'on' ? 'true' : 'false');
localStorage.setItem('ratingtracker.sparklines', next);
});
});
// ── Expandable row keyboard support ───────────────
document.addEventListener('keydown', function(e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = e.target;
if (!row.classList || !row.classList.contains('expandable-row')) return;
e.preventDefault();
const pdgaNumber = row.id.replace('row-', '');
togglePlayerHistory(parseInt(pdgaNumber, 10));
});
+24
View File
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
{
"packages": {
".": {
"release-type": "node",
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "chore", "section": "Miscellaneous" },
{ "type": "refactor", "section": "Code Refactoring" }
]
}
}
}
+16 -3369
View File
File diff suppressed because it is too large Load Diff
+184
View File
@@ -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 };
+10
View File
@@ -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;
+79
View File
@@ -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
};
+282
View File
@@ -0,0 +1,282 @@
const { db } = require('../db');
const { parseDate } = require('../services/rating-calculator');
function getPlayerFromDB(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get(
'SELECT * FROM players WHERE pdga_number = ?',
[pdgaNumber],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
}
function savePlayerToDB(playerData) {
return new Promise((resolve, reject) => {
db.run(
`INSERT OR REPLACE INTO players (pdga_number, name, current_rating, rating_change, last_updated)
VALUES (?, ?, ?, ?, datetime('now'))`,
[playerData.pdgaNumber, playerData.name, playerData.rating, playerData.ratingChange],
function(err) {
if (err) reject(err);
else resolve(this.lastID);
}
);
});
}
function getRatingHistoryFromDB(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return resolve(null);
db.all(
'SELECT * FROM rating_history WHERE player_id = ? ORDER BY date ASC',
[player.id],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
});
}
function saveRatingHistoryToDB(pdgaNumber, ratingHistory) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return reject(new Error('Player not found'));
db.run('DELETE FROM rating_history WHERE player_id = ?', [player.id], (err) => {
if (err) return reject(err);
if (ratingHistory.length === 0) {
return resolve();
}
let completed = 0;
const total = ratingHistory.length;
ratingHistory.forEach(entry => {
const parsedDate = parseDate(entry.date);
db.run(
'INSERT INTO rating_history (player_id, date, rating) VALUES (?, ?, ?)',
[player.id, parsedDate.toISOString().split('T')[0], entry.rating],
(err) => {
if (err) return reject(err);
completed++;
if (completed === total) {
resolve();
}
}
);
});
});
});
});
}
function getRoundHistoryFromDB(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return resolve([]);
db.all(
'SELECT * FROM round_history WHERE player_id = ? ORDER BY date DESC',
[player.id],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
});
}
function getLastRoundUpdateDate(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get(
'SELECT last_round_update FROM players WHERE pdga_number = ?',
[pdgaNumber],
(err, row) => {
if (err) reject(err);
else resolve(row ? row.last_round_update : null);
}
);
});
}
function updateLastRoundUpdateDate(pdgaNumber) {
return new Promise((resolve, reject) => {
db.run(
'UPDATE players SET last_round_update = CURRENT_TIMESTAMP WHERE pdga_number = ?',
[pdgaNumber],
function(err) {
if (err) reject(err);
else resolve();
}
);
});
}
function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return reject(new Error('Player not found'));
const processRounds = () => {
if (roundData.length === 0) {
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (err) => {
if (err) reject(err);
else resolve();
});
return;
}
const stmt = db.prepare('INSERT OR REPLACE INTO round_history (player_id, date, competition_name, rating) VALUES (?, ?, ?, ?)');
for (const round of roundData) {
stmt.run([player.id, round.date.toISOString().split('T')[0], round.competition || 'Unknown', round.rating]);
}
stmt.finalize((err) => {
if (err) {
reject(err);
} else {
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (updateErr) => {
if (updateErr) reject(updateErr);
else resolve();
});
}
});
};
if (!isIncremental) {
db.run('DELETE FROM round_history WHERE player_id = ?', [player.id], (err) => {
if (err) return reject(err);
processRounds();
});
} else {
processRounds();
}
});
});
}
function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) {
return new Promise((resolve, reject) => {
db.run(
'UPDATE players SET predicted_rating = ?, std_dev = ? WHERE pdga_number = ?',
[predictedRating, stdDev, pdgaNumber],
function(err) {
if (err) reject(err);
else resolve();
}
);
});
}
/**
* Returns monthly rating snapshots for one player (latest entry per calendar month),
* ordered oldest → newest. At most `months` entries; [] if none.
*/
function getMonthlyHistory(pdgaNumber, months = 12) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return resolve([]);
db.all(
`SELECT rating
FROM rating_history
WHERE player_id = ?
AND date IN (
SELECT MAX(date)
FROM rating_history
WHERE player_id = ?
GROUP BY strftime('%Y-%m', date)
)
ORDER BY date DESC
LIMIT ?`,
[player.id, player.id, months],
(err, rows) => {
if (err) return reject(err);
resolve(rows.map(r => r.rating).reverse());
}
);
});
});
}
/**
* Fetches the last `months` monthly rating snapshots for ALL players in one query.
* Returns a Map<pdgaNumber, number[]> (oldest → newest per player).
* Use this in bulk-fetch paths to avoid N+1 queries.
*/
function getAllMonthlyHistoriesFromDB(months = 12) {
return new Promise((resolve, reject) => {
db.all(
`SELECT p.pdga_number, rh.date, rh.rating
FROM rating_history rh
JOIN players p ON rh.player_id = p.id
INNER JOIN (
SELECT player_id, MAX(date) AS max_date
FROM rating_history
GROUP BY player_id, strftime('%Y-%m', date)
) latest ON rh.player_id = latest.player_id AND rh.date = latest.max_date
ORDER BY p.pdga_number, rh.date ASC`,
[],
(err, rows) => {
if (err) return reject(err);
const map = new Map();
for (const row of rows) {
if (!map.has(row.pdga_number)) map.set(row.pdga_number, []);
map.get(row.pdga_number).push(row.rating);
}
// Trim each player's history to the requested window
for (const [key, arr] of map) {
if (arr.length > months) map.set(key, arr.slice(-months));
}
resolve(map);
}
);
});
}
function getLastRefresh() {
return new Promise((resolve, reject) => {
db.get(
'SELECT MAX(last_updated) AS lastRefresh FROM players',
[],
(err, row) => {
if (err) reject(err);
else resolve(row ? row.lastRefresh : null);
}
);
});
}
module.exports = {
getPlayerFromDB,
savePlayerToDB,
getRatingHistoryFromDB,
saveRatingHistoryToDB,
getRoundHistoryFromDB,
getLastRoundUpdateDate,
updateLastRoundUpdateDate,
saveRoundHistoryToDB,
savePredictedRatingToDB,
getLastRefresh,
getMonthlyHistory,
getAllMonthlyHistoriesFromDB
};
+372
View File
@@ -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;
+23
View File
@@ -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;
+447
View File
@@ -0,0 +1,447 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
const { getPlayerFromDB, savePlayerToDB, getRatingHistoryFromDB, saveRatingHistoryToDB, getRoundHistoryFromDB, getLastRoundUpdateDate, updateLastRoundUpdateDate, saveRoundHistoryToDB, savePredictedRatingToDB } = require('../models/player');
const { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHistory } = require('../scrapers/player-http');
const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer');
const { launchBrowser } = require('../scrapers/browser');
const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service');
const { getTopbarLocals } = require('../services/topbar-service');
const { calculatePredictedRating } = require('../services/rating-calculator');
const logger = require('../logger');
let refreshInProgress = false;
router.post('/api/refresh-all', async (req, res, next) => {
if (refreshInProgress) {
logger.info('refresh-all already in progress, rejecting');
return res.status(409).json({ error: 'Refresh already in progress' });
}
refreshInProgress = true;
try {
try {
await refreshAllPlayersInDB();
} catch (err) {
logger.error({ err }, 'refresh-all failed');
}
const page = req.body?.page === 'courses' ? 'courses' : 'players';
const locals = await getTopbarLocals();
res.render('../partials/topbar', { activePage: page, ...locals });
} catch (err) {
next(err);
} finally {
refreshInProgress = false;
}
});
router.get('/partials/ratings-table', async (req, res) => {
try {
const ratings = await getAllRatingsFromDB();
res.render('../partials/ratings-table', { ratings });
} catch (error) {
res.status(500).send('<p>Error loading ratings. Please try again.</p>');
}
});
router.get('/partials/player-history/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
let history = await getRatingHistoryFromDB(pdgaNumber);
if (!history || history.length === 0) {
const html = await fetchRatingHistory(pdgaNumber);
history = parseRatingHistory(html);
try {
await saveRatingHistoryToDB(pdgaNumber, history);
} catch (dbErr) {
logger.error('Failed to save rating history:', dbErr.message);
}
}
const formattedHistory = (history || []).map(row => ({
date: row.date,
rating: row.rating,
displayDate: new Date(row.date).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })
}));
const player = await getPlayerDataFromDB(pdgaNumber);
res.render('../partials/player-history', { pdgaNumber, history: formattedHistory, player });
} catch (error) {
logger.error('Error loading player history:', error.message);
res.status(500).send('<div class="loading-chart">Error loading rating history</div>');
}
});
router.get('/api/ratings', async (req, res) => {
try {
const ratings = await getAllRatingsFromDB();
res.json(ratings);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch ratings' });
}
});
router.get('/api/ratings/progress', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
});
const progressCallback = (progress) => {
res.write(`data: ${JSON.stringify(progress)}\n\n`);
};
getAllRatingsFromDB(progressCallback).then(ratings => {
res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`);
res.end();
}).catch(error => {
res.write(`data: ${JSON.stringify({ status: 'error', error: error.message })}\n\n`);
res.end();
});
req.on('close', () => {
res.end();
});
});
router.get('/api/rating-history/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
const cachedHistory = await getRatingHistoryFromDB(pdgaNumber);
if (cachedHistory && cachedHistory.length > 0) {
logger.info(`Using cached rating history from DB for PDGA ${pdgaNumber}`);
const formattedHistory = cachedHistory.map(row => ({
date: row.date,
rating: row.rating,
displayDate: new Date(row.date).toLocaleDateString('en-US', {
day: '2-digit',
month: 'short',
year: 'numeric'
})
}));
res.json({
pdgaNumber: parseInt(pdgaNumber),
history: formattedHistory
});
return;
}
logger.info(`Fetching rating history for PDGA ${pdgaNumber}...`);
const html = await fetchRatingHistory(pdgaNumber);
const history = parseRatingHistory(html);
try {
await saveRatingHistoryToDB(pdgaNumber, history);
logger.info(`Saved rating history for PDGA ${pdgaNumber} to database`);
} catch (dbErr) {
logger.error(`Failed to save rating history to database:`, dbErr.message);
}
res.json({
pdgaNumber: parseInt(pdgaNumber),
history
});
} catch (error) {
logger.error('Error fetching rating history:', error.message);
res.status(500).json({ error: 'Failed to fetch rating history' });
}
});
router.post('/api/clear-cache', (req, res) => {
try {
db.run('UPDATE players SET last_updated = datetime("now", "-25 hours"), last_round_update = NULL', (err) => {
if (err) {
logger.error('Error clearing database cache:', err);
res.status(500).json({ error: 'Failed to clear database cache' });
return;
}
logger.info('Database cache cleared - all players will be refreshed on next request');
res.json({
success: true,
message: 'Cache cleared - database reset'
});
});
} catch (error) {
logger.error('Error clearing cache:', error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
router.get('/api/search-player/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
logger.info(`Searching for player with PDGA number ${pdgaNumber}`);
const existingPlayer = await getPlayerFromDB(pdgaNumber);
if (existingPlayer) {
return res.json({
alreadyExists: true,
player: {
pdgaNumber: existingPlayer.pdga_number,
name: existingPlayer.name,
rating: existingPlayer.current_rating,
ratingChange: existingPlayer.rating_change
}
});
}
const html = await fetchPlayerDataHTTP(pdgaNumber);
const playerData = parsePlayerData(html, pdgaNumber);
if (playerData.name === 'Unknown' || !playerData.name) {
return res.status(404).json({ error: 'Player not found' });
}
res.json({
alreadyExists: false,
player: playerData
});
} catch (error) {
logger.error('Error searching for player:', error.message);
res.status(500).json({ error: 'Failed to search for player' });
}
});
router.post('/api/add-player', async (req, res) => {
try {
const { pdgaNumber } = req.body;
if (!pdgaNumber) {
return res.status(400).json({ error: 'PDGA number is required' });
}
logger.info(`Adding player with PDGA number ${pdgaNumber}`);
const existingPlayer = await getPlayerFromDB(pdgaNumber);
if (existingPlayer) {
return res.status(409).json({
error: 'Player already exists',
player: {
pdgaNumber: existingPlayer.pdga_number,
name: existingPlayer.name,
rating: existingPlayer.current_rating
}
});
}
const html = await fetchPlayerDataHTTP(pdgaNumber);
const playerData = parsePlayerData(html, pdgaNumber);
if (playerData.name === 'Unknown' || !playerData.name) {
return res.status(404).json({ error: 'Player not found' });
}
await savePlayerToDB(playerData);
logger.info(`Successfully added player: ${playerData.name} (#${pdgaNumber})`);
res.json({
success: true,
player: playerData
});
} catch (error) {
logger.error('Error adding player:', error.message);
res.status(500).json({ error: 'Failed to add player' });
}
});
router.post('/api/refresh-player/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
logger.info(`Manually refreshing player data for PDGA ${pdgaNumber}`);
const html = await fetchPlayerDataHTTP(pdgaNumber);
const playerData = parsePlayerData(html, pdgaNumber);
await savePlayerToDB(playerData);
res.json({
success: true,
player: playerData
});
} catch (error) {
logger.error('Error refreshing player data:', error.message);
res.status(500).json({ error: 'Failed to refresh player data' });
}
});
router.post('/api/refresh-rating-history/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
logger.info(`=== Manually refreshing rating history for PDGA ${pdgaNumber} ===`);
const startTime = Date.now();
const html = await fetchRatingHistory(pdgaNumber);
const fetchTime = Date.now() - startTime;
logger.info(`HTML fetch completed in ${fetchTime}ms, received ${html.length} bytes`);
const parseStartTime = Date.now();
const history = parseRatingHistory(html);
const parseTime = Date.now() - parseStartTime;
logger.info(`Parsing completed in ${parseTime}ms, found ${history.length} history entries`);
if (history.length > 0) {
logger.debug({ entries: history.slice(0, 3) }, 'Sample history entries');
} else {
logger.debug({ htmlSample: html.substring(0, 500) }, 'No history entries found');
}
const dbStartTime = Date.now();
await saveRatingHistoryToDB(pdgaNumber, history);
const dbTime = Date.now() - dbStartTime;
logger.info(`Database save completed in ${dbTime}ms`);
const formattedHistory = history.map(entry => ({
date: entry.date,
rating: entry.rating,
displayDate: entry.displayDate
}));
logger.info(`=== Rating history refresh completed for PDGA ${pdgaNumber} ===`);
res.json({
success: true,
history: formattedHistory
});
} catch (error) {
logger.error(`=== Error refreshing rating history for PDGA ${req.params.pdgaNumber} ===`);
logger.error('Error type:', error.constructor.name);
logger.error('Error message:', error.message);
res.status(500).json({
error: 'Failed to refresh rating history',
details: error.message,
code: error.code
});
}
});
router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
req.setTimeout(600000);
res.setTimeout(600000);
let browser = null;
const { pdgaNumber } = req.params;
try {
const lastRoundUpdate = await getLastRoundUpdateDate(pdgaNumber);
const sinceDate = lastRoundUpdate ? new Date(lastRoundUpdate) : null;
if (sinceDate) {
const hoursSinceUpdate = (Date.now() - sinceDate.getTime()) / (1000 * 60 * 60);
if (hoursSinceUpdate < 24) {
const hoursRemaining = Math.ceil(24 - hoursSinceUpdate);
return res.status(429).json({
error: 'Rate limit exceeded',
message: `Prediction can only be refreshed once every 24 hours. Please try again in ${hoursRemaining} hour(s).`,
lastUpdate: sinceDate.toISOString(),
hoursRemaining: hoursRemaining
});
}
}
const isIncremental = !!sinceDate;
logger.info(`${isIncremental ? 'Incrementally updating' : 'Fully refreshing'} round history for PDGA ${pdgaNumber}${sinceDate ? ` since ${sinceDate.toDateString()}` : ''}`);
browser = await launchBrowser();
let officialHistory;
try {
officialHistory = await getOfficialRatingHistory(browser, pdgaNumber);
if (officialHistory.length > 0) {
await saveRatingHistoryToDB(pdgaNumber, officialHistory);
}
} catch (historyError) {
logger.error('Failed to fetch official history:', historyError.message);
officialHistory = [];
}
let allRounds = [];
try {
logger.info(`Using optimized approach: /details + new tournaments only for PDGA ${pdgaNumber}...`);
allRounds = await getOptimizedPlayerRounds(browser, pdgaNumber);
if (allRounds.length > 0) {
const roundsForDB = allRounds.map(round => ({
rating: round.rating,
date: round.date,
competition: round.competition
}));
await saveRoundHistoryToDB(pdgaNumber, roundsForDB, false);
logger.info(`✓ Saved ${allRounds.length} rounds using optimized approach`);
await updateLastRoundUpdateDate(pdgaNumber);
} else {
logger.info(' No rounds found');
}
} catch (detailsError) {
logger.error('Failed to fetch rounds using optimized approach:', detailsError.message);
allRounds = [];
}
await browser.close();
browser = null;
const dbRounds = await getRoundHistoryFromDB(pdgaNumber);
const roundsForPrediction = dbRounds.map(round => ({
rating: round.rating,
date: new Date(round.date),
competition: round.competition_name
}));
const result = calculatePredictedRating(roundsForPrediction);
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
const officialCount = allRounds.filter(r => r.source === 'official').length;
const newCount = allRounds.filter(r => r.source === 'new').length;
res.json({
success: true,
predictedRating: result.rating,
stdDev: result.stdDev,
debugLog: result.debugLog,
totalRounds: roundsForPrediction.length,
officialRounds: officialCount,
newRounds: newCount,
approach: 'optimized',
message: `Used /details (${officialCount} rounds) + new tournaments (${newCount} rounds)`
});
} catch (error) {
logger.error(`=== Error refreshing round history for PDGA ${pdgaNumber} ===`);
logger.error('Error type:', error.constructor.name);
logger.error('Error message:', error.message);
if (browser) {
try {
await browser.close();
} catch (closeError) {
logger.error('Error closing browser:', closeError.message);
}
}
res.status(500).json({
error: 'Failed to refresh round history',
details: error.message,
errorType: error.constructor.name,
timestamp: new Date().toISOString(),
suggestion: error.message.includes('socket hang up') ?
'Rate limited by PDGA - try again in a few minutes.' :
error.message.includes('timeout') ?
'PDGA pages are loading slowly - try again later.' :
'Tournament scraping failed - check server logs for details'
});
}
});
module.exports = router;
+21
View File
@@ -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 };
+350
View File
@@ -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
};
+210
View File
@@ -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 };
+318
View File
@@ -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
};
+348
View File
@@ -0,0 +1,348 @@
const { db } = require('../db');
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB } = require('../models/player');
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
const { calculatePredictedRating } = require('./rating-calculator');
const logger = require('../logger');
// Derives previous-month rating and the delta to it. Prefers PDGA's reported
// rating_change (canonical), falls back to our own monthly snapshots when
// rating_change is missing — common for players whose latest scrape failed.
function deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory) {
if (rating != null && rawRatingChange != null) {
return { lastMonthRating: rating - rawRatingChange, ratingChange: rawRatingChange };
}
if (rating != null && monthlyHistory && monthlyHistory.length >= 1) {
// The "last month" snapshot depends on whether current_rating is already in
// history. If equal, current is the most recent entry — last month is the one
// before it. If not, current is newer than history — the latest entry IS last month.
const lastIdx = monthlyHistory.length - 1;
const lastMonth = (monthlyHistory[lastIdx] === rating)
? (monthlyHistory.length >= 2 ? monthlyHistory[lastIdx - 1] : null)
: monthlyHistory[lastIdx];
if (lastMonth != null) {
return { lastMonthRating: lastMonth, ratingChange: rating - lastMonth };
}
}
return { lastMonthRating: null, ratingChange: rawRatingChange };
}
async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } = {}) {
try {
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
if (cachedPlayer) {
logger.debug(`Loading PDGA ${pdgaNumber} from DB (source of truth)`);
let predictedRating = cachedPlayer.predicted_rating;
let stdDev = cachedPlayer.std_dev;
if (!predictedRating || predictedRating === 0) {
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
stdDev = updatedPlayer?.std_dev;
}
const rating = cachedPlayer.current_rating;
const rawRatingChange = cachedPlayer.rating_change;
const resolvedPredicted = predictedRating > 0 ? predictedRating : null;
const resolvedStdDev = stdDev > 0 ? stdDev : null;
// Skip in bulk-fetch paths where caller supplies history via getAllMonthlyHistoriesFromDB
const monthlyHistory = includeMonthlyHistory
? await getMonthlyHistory(cachedPlayer.pdga_number)
: [];
const { lastMonthRating, ratingChange } = deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory);
return {
pdgaNumber: cachedPlayer.pdga_number,
name: cachedPlayer.name,
rating,
ratingChange,
predictedRating: resolvedPredicted,
stdDev: resolvedStdDev,
lastMonthRating,
// gap between next predicted update and current rating (null when either is missing)
deltaPredicted: (resolvedPredicted != null && rating != null) ? resolvedPredicted - rating : null,
monthlyHistory
};
}
return null;
} catch (err) {
logger.error(`Database error for PDGA ${pdgaNumber}:`, err.message);
return null;
}
}
async function scrapePDGARating(pdgaNumber, retries = 3) {
logger.info(`Refreshing PDGA ${pdgaNumber} from PDGA website`);
for (let attempt = 1; attempt <= retries; attempt++) {
try {
logger.info(`Attempt ${attempt}/${retries} for PDGA ${pdgaNumber} (using HTTP)`);
const html = await fetchPlayerDataHTTP(pdgaNumber);
const result = parsePlayerData(html, pdgaNumber);
try {
await savePlayerToDB(result);
logger.info(`Saved PDGA ${pdgaNumber} to database`);
} catch (dbErr) {
logger.error(`Failed to save PDGA ${pdgaNumber} to database:`, dbErr.message);
}
logger.info(`Successfully scraped PDGA ${pdgaNumber} on attempt ${attempt}`);
return result;
} catch (error) {
logger.error(`Attempt ${attempt}/${retries} failed for PDGA ${pdgaNumber}:`, error.message);
if (attempt === retries) {
return {
pdgaNumber,
name: 'Error',
rating: 0,
ratingChange: null,
predictedRating: null
};
}
let retryDelay = 2000 * attempt;
if (error.rateLimitInfo) {
const retryAfter = error.rateLimitInfo.headers['retry-after'];
if (retryAfter) {
retryDelay = Math.max(retryDelay, (parseInt(retryAfter) + 1) * 1000);
logger.warn(`Using Retry-After header: waiting ${retryDelay/1000}s`);
}
}
if (error.code === 'ECONNRESET') {
retryDelay = Math.max(retryDelay, 10000);
logger.warn(`Connection reset detected: waiting ${retryDelay/1000}s`);
}
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
async function getPredictedRatingFromDB(pdgaNumber) {
try {
const roundHistory = await getRoundHistoryFromDB(pdgaNumber);
if (roundHistory.length > 0) {
logger.debug(`Using ${roundHistory.length} cached rounds for PDGA ${pdgaNumber} prediction`);
const roundRatings = roundHistory.map(round => ({
rating: round.rating,
date: new Date(round.date),
competition: round.competition_name || 'Unknown'
}));
const result = calculatePredictedRating(roundRatings);
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
return result.rating;
}
return 0;
} catch (err) {
logger.error(`Error getting predicted rating from DB for ${pdgaNumber}:`, err.message);
return 0;
}
}
async function getAllRatingsFromDB(progressCallback = null) {
try {
const allPlayers = await new Promise((resolve, reject) => {
db.all(
'SELECT pdga_number, name, current_rating, rating_change FROM players ORDER BY pdga_number',
[],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
logger.info(`Loading ${allPlayers.length} players from database...`);
// Fetch all monthly histories in one query so the per-player loop doesn't add N extra queries
const monthlyHistoryMap = await getAllMonthlyHistoriesFromDB(12);
const ratings = [];
const total = allPlayers.length;
for (let i = 0; i < allPlayers.length; i++) {
const player = allPlayers[i];
const pdgaNumber = player.pdga_number;
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'loading'
});
}
try {
const playerData = await getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory: false });
if (playerData) {
playerData.monthlyHistory = monthlyHistoryMap.get(pdgaNumber) ?? [];
// Re-derive now that history is attached — bulk path skipped includeMonthlyHistory
const derived = deriveMonthlyDeltas(playerData.rating, player.rating_change, playerData.monthlyHistory);
playerData.lastMonthRating = derived.lastMonthRating;
playerData.ratingChange = derived.ratingChange;
ratings.push(playerData);
}
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'completed',
name: playerData ? playerData.name : player.name
});
}
} catch (error) {
logger.error(`Failed to load PDGA ${pdgaNumber} from database:`, error.message);
const errorRating = player.current_rating;
const errorRatingChange = player.rating_change;
const errorData = {
pdgaNumber: parseInt(pdgaNumber),
name: player.name || 'Database Error',
rating: errorRating,
ratingChange: errorRatingChange,
predictedRating: null,
stdDev: null,
lastMonthRating: (errorRating != null && errorRatingChange != null) ? errorRating - errorRatingChange : null,
deltaPredicted: null,
monthlyHistory: []
};
ratings.push(errorData);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'error',
name: player.name || 'Database Error'
});
}
}
}
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
} catch (error) {
logger.error('Error loading players from database:', error);
return [];
}
}
async function refreshAllPlayersInDB(progressCallback = null) {
try {
const allPlayers = await new Promise((resolve, reject) => {
db.all(
'SELECT pdga_number, name FROM players ORDER BY pdga_number',
[],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
logger.info(`Refreshing ${allPlayers.length} players from database...`);
const ratings = [];
const total = allPlayers.length;
for (let i = 0; i < allPlayers.length; i++) {
const player = allPlayers[i];
const pdgaNumber = player.pdga_number;
logger.info(`Refreshing PDGA ${pdgaNumber}... (${i + 1}/${total})`);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'loading'
});
}
try {
const playerData = await scrapePDGARating(pdgaNumber);
ratings.push(playerData);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'completed',
name: playerData.name
});
}
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
logger.error(`Failed to refresh PDGA ${pdgaNumber}:`, error.message);
const errorData = {
pdgaNumber: parseInt(pdgaNumber),
name: player.name || 'Error',
rating: 0,
ratingChange: null,
predictedRating: null
};
ratings.push(errorData);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'error',
name: player.name || 'Error'
});
}
}
}
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
} catch (error) {
logger.error('Error refreshing all players:', error);
return [];
}
}
/**
* Aggregates KPI summary stats from an already-fetched player array.
* All fields are derived from the player list no extra DB queries.
*/
function computeKpis(players) {
const active = players.filter(p => p.rating != null && p.rating > 0);
const avg = active.length > 0
? Math.round(active.reduce((sum, p) => sum + p.rating, 0) / active.length)
: null;
return {
tracked: players.length,
active: active.length,
avg,
climbing: players.filter(p => p.ratingChange != null && p.ratingChange > 0).length,
slipping: players.filter(p => p.ratingChange != null && p.ratingChange < 0).length
};
}
module.exports = {
getPlayerDataFromDB,
scrapePDGARating,
getPredictedRatingFromDB,
getAllRatingsFromDB,
refreshAllPlayersInDB,
computeKpis
};
+234
View File
@@ -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 };
+43
View File
@@ -0,0 +1,43 @@
const { getLastRefresh } = require('../models/player');
const logger = require('../logger');
function formatRelative(isoString) {
if (!isoString) return 'Never';
const then = new Date(isoString.replace(' ', 'T') + (isoString.endsWith('Z') ? '' : 'Z'));
const diffMs = Date.now() - then.getTime();
if (Number.isNaN(diffMs) || diffMs < 0) return 'Just now';
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return 'Just now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min} min ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr} h ago`;
const day = Math.floor(hr / 24);
if (day === 1) return 'Yesterday';
if (day < 7) return `${day} days ago`;
return then.toISOString().slice(0, 10);
}
// First Tuesday of next month — approximation of PDGA's monthly cycle
function computeNextUpdate(now = new Date()) {
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1; // next month, may roll over
const candidate = new Date(Date.UTC(month === 12 ? year + 1 : year, month === 12 ? 0 : month, 1));
// 0=Sun, 1=Mon, 2=Tue
const offset = (2 - candidate.getUTCDay() + 7) % 7;
candidate.setUTCDate(1 + offset);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][candidate.getUTCDay()]} ${candidate.getUTCDate()} ${months[candidate.getUTCMonth()]}`;
}
async function getTopbarLocals() {
try {
const lastIso = await getLastRefresh();
return { lastRefresh: formatRelative(lastIso), nextUpdate: computeNextUpdate() };
} catch (err) {
logger.warn({ err }, 'topbar locals fallback');
return { lastRefresh: 'Unknown', nextUpdate: computeNextUpdate() };
}
}
module.exports = { getTopbarLocals };
+31
View File
@@ -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
}) %>
+115
View File
@@ -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()">&times;</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()">&times;</button>
<div class="modal-header" id="add-player-modal-header">Confirm Player</div>
<div class="modal-body" id="add-player-modal-body">Loading...</div>
<div class="modal-footer" id="add-player-modal-footer">
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Cancel</button>
<button class="btn btn-confirm" id="confirm-add-btn" onclick="confirmAddPlayer()">Add Player</button>
</div>
</div>
</div>
`; %>
<%- include('../partials/layout', {
title: 'PDGA Ratings',
activePage: 'players',
cssFiles: ['players.css'],
jsFiles: ['tooltips.js', 'chart.js', 'players.js'],
initScript: 'setupTooltipsAfterSwap();',
body: body,
modals: modals
}) %>
+71
View File
@@ -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">&#9660;</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>
<% } %>
<% } %>
+46
View File
@@ -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>
<% } %>
+12
View File
@@ -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>
+42
View File
@@ -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>
+4
View File
@@ -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>
+42
View File
@@ -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>
+100
View File
@@ -0,0 +1,100 @@
<%
function renderSparkline(values) {
if (!values || values.length < 2) return '';
var w = 96, h = 28;
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
var xStep = w / (values.length - 1);
var pts = values.map(function(v, i) {
return {
x: (i * xStep).toFixed(1),
y: (((max - v) / range) * (h - 4) + 2).toFixed(1)
};
});
var linePath = pts.map(function(p, i) {
return (i === 0 ? 'M' : 'L') + ' ' + p.x + ' ' + p.y;
}).join(' ');
var last = pts[pts.length - 1];
var areaPath = linePath + ' L ' + last.x + ' ' + h + ' L 0 ' + h + ' Z';
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" class="spark" aria-hidden="true">' +
'<path d="' + areaPath + '" style="fill:var(--accent);fill-opacity:0.10"/>' +
'<path d="' + linePath + '" style="stroke:var(--accent);stroke-width:1.5;fill:none;stroke-linejoin:round;stroke-linecap:round"/>' +
'<circle cx="' + last.x + '" cy="' + last.y + '" r="2.5" style="fill:var(--accent)"/>' +
'</svg>';
}
%>
<% if (ratings.length === 0) { %>
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No players tracked yet.</p>
<% } else { %>
<table>
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-player">Player</th>
<th class="col-rating">Rating<span class="th-hint">+ Δ since last update</span></th>
<th class="col-predicted">Predicted<span class="th-hint">+ gap from today</span></th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
<% ratings.forEach(function(player, index) {
const sparklineSvg = renderSparkline(player.monthlyHistory || []);
%>
<tr id="row-<%= player.pdgaNumber %>" class="expandable-row" tabindex="0" onclick="togglePlayerHistory(<%= player.pdgaNumber %>)">
<td class="col-rank">
<span class="rank-num mono"><%= index + 1 %></span>
</td>
<td class="col-player">
<div class="cell-name">
<span class="player-name"><a href="https://www.pdga.com/player/<%= player.pdgaNumber %>" target="_blank" onclick="event.stopPropagation()"><%= player.name %></a></span>
<span class="pdga-num mono">#<%= player.pdgaNumber %></span>
</div>
</td>
<td class="col-rating cell-rating">
<% if (player.rating) { %>
<div class="rating-stack">
<span class="rating-value rating-big mono" data-rating="<%= player.rating || '' %>" data-stddev="<%= player.stdDev || '' %>" data-pdga="<%= player.pdgaNumber %>"><%= player.rating %></span>
<%- include('delta-pill', { value: player.ratingChange }) %>
<% if (sparklineSvg) { %><span class="sparkline"><%- sparklineSvg %></span><% } %>
</div>
<% } else { %>
<span class="rating-pending">Click to load</span>
<% } %>
<div class="std-dev-tooltip" id="tooltip-rating-<%= player.pdgaNumber %>"></div>
</td>
<td class="col-predicted" id="predicted-<%= player.pdgaNumber %>">
<% if (player.predictedRating) { %>
<div class="pred-stack">
<span class="predicted-value pred-num mono" data-stddev="<%= player.stdDev || '' %>" data-pdga="<%= player.pdgaNumber %>"><%= player.predictedRating %></span>
<%- include('delta-pill', { value: player.deltaPredicted, extraClass: 'delta-predicted-pill' }) %>
</div>
<% } else { %>
<span class="rating-pending">—</span>
<% } %>
<div class="std-dev-tooltip" id="tooltip-stddev-<%= player.pdgaNumber %>"></div>
</td>
<td class="col-actions cell-actions" onclick="event.stopPropagation()">
<button class="icon-btn refresh-icon" onclick="refreshPlayerData(<%= player.pdgaNumber %>)" title="Refresh rating + prediction" aria-label="Refresh rating and prediction">
<i class="fas fa-sync-alt"></i>
</button>
<button class="icon-btn icon-chev" onclick="togglePlayerHistory(<%= player.pdgaNumber %>)" title="Expand row" aria-label="Expand">
<i class="fas fa-chevron-down"></i>
</button>
</td>
</tr>
<tr id="history-<%= player.pdgaNumber %>" class="expanded-content">
<td colspan="5" class="expanded-cell">
<div id="history-content-<%= player.pdgaNumber %>">
<div class="loading-chart">Click to load rating history...</div>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
<% } %>
+46
View File
@@ -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>