Compare commits

...

117 Commits

Author SHA1 Message Date
Release Bot 8ee5cc3861 1.4.1
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 23s
Build and deploy / deploy (push) Successful in 8s
2026-06-01 07:04:42 +00:00
shcizo 2561ee12ef Merge pull request 'fix: parse latest tournament from recent-events list on player page (#24)' (#25) from fix/parse-recent-events-tournament-24 into main
Release / release (push) Successful in 25s
2026-06-01 09:04:13 +02:00
Samuel Enocsson 0d2f0fa3a8 fix: skip recent-events tournament when extracted date predates afterDate (#24) 2026-06-01 08:57:51 +02:00
Samuel Enocsson ec3ae872da fix: parse latest tournament from recent-events list on player page (#24) 2026-06-01 08:53:12 +02:00
Release Bot a90f2d0e86 1.4.0
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 21s
Build and deploy / deploy (push) Successful in 7s
2026-05-25 09:18:47 +00:00
shcizo f8233960d2 Merge pull request 'feat: show excluded rounds count and cutoff rating in player history (#21)' (#23) from feat/show-excluded-rounds-count-21 into main
Release / release (push) Successful in 7s
2026-05-25 11:18:35 +02:00
Samuel Enocsson 98a6c6be2e feat: show cutoff rating threshold in player history accordion (#21) 2026-05-25 11:12:01 +02:00
Samuel Enocsson 9138299ae0 Merge remote-tracking branch 'origin/main' into feat/show-excluded-rounds-count-21 2026-05-25 11:01:53 +02:00
Release Bot 3275241aa7 1.3.0
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 25s
Build and deploy / deploy (push) Successful in 8s
2026-05-25 08:52:17 +00:00
shcizo 6faddc6232 Merge pull request 'feat: Courses-redesign + Tjing-import (#8)' (#22) from feat/courses-redesign-tjing-import-8 into main
Release / release (push) Successful in 7s
2026-05-25 10:52:06 +02:00
Samuel Enocsson cad14def56 style: align course row typography with Players (#8) 2026-05-25 10:36:43 +02:00
Samuel Enocsson 75b2360e96 feat: add table header row to Courses matching Players style (#8) 2026-05-25 10:33:47 +02:00
Samuel Enocsson 2035ae0efc fix: use FontAwesome icons matching Players page (#8) 2026-05-25 10:29:34 +02:00
Samuel Enocsson 88396c9220 fix: remove EJS comment inside template literal causing parse error (#8) 2026-05-25 10:25:59 +02:00
Samuel Enocsson 9cb78c9c98 fix: address code-review findings from pass 1 + 2 (#8)
- Fix saveCourseToDB returning 0 on conflict by falling back to SELECT
- Fix inactive layouts showing 'Never played' when last_played exists
- Add .icon-btn.spinning to courses.css for refresh button feedback
- Remove duplicate .btn-primary from courses.css (use shared.css version)
- Tokenize rating tier colors into --rating-tier-{high,mid,low} CSS vars
- Convert var to const/let throughout courses.js
- Fix logger.error calls to use {err} object form (pino convention)
- Extract RATING_TIER_HIGH/MID constants in course-layouts.ejs scriptlet
- Remove dead href='#' View all link from courses.ejs (deferred)
- Pass total prop explicitly from course-table.ejs to course-cards.ejs
- Remove dead #search-results-info selector from mobile.css
- Remove redundant .replace(/"/g, '"') from data attributes in course-table.ejs
2026-05-25 09:54:15 +02:00
Samuel Enocsson f2e30c62aa fix: zero excluded count in fallback, drop debug-icon orphan, align ejs guard (#21) 2026-05-25 09:44:46 +02:00
Samuel Enocsson 4bbf6d9728 feat: redesign Courses page with tabs + restore Tjing import (#8)
- Restore src/scrapers/tjing.js with AbortController timeout (8s),
  error-object returns, and verbatim GraphQL queries
- Add getOrCreateLayout() to src/models/course.js
- New /api/tjing/search and /api/tjing/import/:tjingId routes;
  course-table route now includes layoutCount/activeLayoutCount via
  LEFT JOIN aggregation
- Rewrite courses.ejs: action-card with Find/Import tabs, results bar,
  HTMX course-table-region with body:refresh trigger
- Rewrite course-table.ejs: CSS-grid div structure replacing <table>,
  lazy-load expanded layouts via JS htmx.ajax
- Rewrite course-layouts.ejs: layout-card chips with rating tier colouring,
  collapsible inactive layouts section
- Rewrite courses.js: tab switching, live client-side filter, count display,
  Tjing search/import using DOM API (no innerHTML with untrusted data)
- Rewrite courses.css: full new design system using project tokens
2026-05-25 09:39:44 +02:00
Samuel Enocsson 0beeb98002 feat: show excluded rounds count in player history accordion (#21) 2026-05-25 09:34:42 +02:00
Release Bot f4c5e963d2 1.2.11
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 21s
Build and deploy / deploy (push) Successful in 11s
2026-05-25 06:04:26 +00:00
shcizo 27d1bef8dd Merge pull request 'fix: move std-dev info to accordion, remove broken tooltip (#19)' (#20) from fix/std-dev-tooltip-positioning-and-discoverability-19 into main
Release / release (push) Successful in 7s
2026-05-25 08:04:14 +02:00
Samuel Enocsson 088e283dcf refactor: split round spread and rating range into separate accordion rows (#19) 2026-05-25 08:02:05 +02:00
Samuel Enocsson 5791d8e34f refactor: move std-dev info to accordion, remove tooltip (#19)
- Add "Round spread" row (±stdDev, range lo–hi) to desktop accordion
  (player-history.ejs) and mobile card expanded section (ratings-cards.ejs)
- Remove .std-dev-tooltip div and .std-dev-inline span from table partial
- Remove stdDevTooltipText, updateStdDevInline, initRatingsTooltips helpers
  and all call sites from players.js
- Remove .std-dev-tooltip and .std-dev-inline CSS rules; drop cursor:help
  from .rating-value
2026-05-25 07:54:46 +02:00
Samuel Enocsson 1ff768e2fa fix: address std-dev inline span refresh + style fixes (#19)
- A: create inline span when missing in refreshRoundHistory (was silently dropped)
- B: updateStdDevInline also called from refreshHistoryThenCalculate
- C: extract stdDevTooltipText + updateStdDevInline helpers; replace 3 call sites
- D: remove margin-left: 4px and bump font-size to 12px on .std-dev-inline
- E: guard against stdDev === 0 in EJS (truthy → != null)
2026-05-23 06:45:39 +02:00
Samuel Enocsson c3fb850de3 fix: reposition std-dev tooltip and surface ±-spread inline (#19) 2026-05-23 06:40:08 +02:00
Release Bot c69efa469e 1.2.10
Release / release (push) Successful in 5s
Build and deploy / build-and-push (push) Successful in 21s
Build and deploy / deploy (push) Successful in 8s
2026-05-22 20:11:32 +00:00
Samuel Enocsson e21e6f2ef0 fix: couple mobile add-bar offset to container padding via CSS var
Release / release (push) Successful in 7s
Previously .mobile-add-bar { margin-bottom: -80px } was hardcoded
to match .container { padding-bottom: 80px }. Now both reference
--m-container-pad-bottom so they can't drift out of sync.
2026-05-22 22:11:20 +02:00
Samuel Enocsson 141dc90db7 ci: add automated release workflow with conventional commits
Release / release (push) Successful in 5s
- New .gitea/workflows/release.yml triggers on push to main
- Scans commits since last v* tag and bumps strict semver
- feat: -> minor, fix: -> patch, !: or BREAKING CHANGE -> major
- Updates package.json + package-lock.json, commits as <version>,
  tags v<version>, pushes both
- Tag push triggers existing deploy.yml (PACKAGES_TOKEN is a PAT so
  cross-workflow triggers work)
- Skips silently when no releaseable commits are found
- Updates CLAUDE.md release section to reflect automation
2026-05-22 22:04:07 +02:00
Samuel Enocsson b4d9305550 1.2.9
Build and deploy / build-and-push (push) Successful in 22s
Build and deploy / deploy (push) Successful in 8s
2026-05-22 21:57:39 +02:00
shcizo e900b86e69 Merge pull request 'feat: mobile UI card layout for players and courses (#16)' (#18) from feat/mobile-ui-card-layout-16 into main 2026-05-22 21:49:48 +02:00
Samuel Enocsson 4dc429b961 fix: hide desktop table-toolbar on mobile + fix sparkline overflow (#16)
- Add .table-toolbar to mobile hide list (was rendering duplicate
  'TRACKED PLAYERS' header above the mobile section-head)
- Change .m-card__body grid to minmax(0, 1fr) auto so the stats
  column can shrink below content size
- Add min-width: 0 to .m-card__stats for proper grid shrinking
- Remove grid-row: span 2 from .m-card__sparkline (single-row grid)
- Remove overflow: visible from .m-chart-spark so the end-dot stays
  within the SVG viewport
2026-05-22 21:44:22 +02:00
Samuel Enocsson 4bcf83d267 style: convert var to const in sparkline toggle block (#16) 2026-05-22 21:32:14 +02:00
Samuel Enocsson 7ab16994c5 chore: remove dead mobile-add-bar.ejs partial (#16) 2026-05-22 21:27:57 +02:00
Samuel Enocsson b51c47dc4c fix: address mobile UI review findings (#16)
- Hide desktop .card-section on mobile, add .m-search-input with same
  HTMX attrs for mobile course search (fixes horizontal overflow)
- Remove dead layoutCount var and .m-layouts-pill block in course-cards
- Remove dead 768px breakpoints from players.css (table hidden at 880px)
- Move .mobile-section-head inside else-block for empty state in both
  ratings-cards and course-cards (fixes section head showing on empty)
- Add tabindex, role=button, aria-expanded, onkeydown to .m-card and
  .m-course-card; toggle aria-expanded in JS toggle functions
- Fix data-history attribute to use <%=  (HTML-escaped) instead of <%-
- Convert var to const/let in all new/changed JS blocks
2026-05-22 21:27:05 +02:00
Samuel Enocsson cc9d8eb4cd feat: mobile UI card layout for players and courses (#16) 2026-05-22 21:07:00 +02:00
Samuel Enocsson e25f66c5d3 1.2.8
Build and deploy / build-and-push (push) Successful in 21s
Build and deploy / deploy (push) Successful in 7s
2026-05-22 15:52:55 +02:00
shcizo 1442396418 Merge pull request 'feat: target rating calculator (#2)' (#17) from feat/target-rating-calculator-2 into main
Reviewed-on: #17
2026-05-22 15:46:05 +02:00
Samuel Enocsson 307dffd3a7 docs: update CLAUDE.md for consolidated deploy.yml workflow 2026-05-22 15:45:04 +02:00
Samuel Enocsson 46f78b42dc 1.2.6
Build and deploy / build-and-push (push) Successful in 20s
Build and deploy / deploy (push) Successful in 3s
2026-05-22 15:27:42 +02:00
Samuel Enocsson d0c278ea1b ci: use full Gitea URL for package-updater-action 2026-05-22 15:27:30 +02:00
Samuel Enocsson d0040901ab 1.2.5
Build and deploy / build-and-push (push) Successful in 21s
Build and deploy / deploy (push) Failing after 2s
2026-05-22 15:25:51 +02:00
Samuel Enocsson c0f9dd5f33 ci: fix updater secret name (UPDATER_API_KEY) 2026-05-22 15:25:38 +02:00
Samuel Enocsson 66e892893f 1.2.4
Build and deploy / build-and-push (push) Successful in 1m29s
Build and deploy / deploy (push) Failing after 2s
2026-05-22 15:23:19 +02:00
Samuel Enocsson 5b9138da25 ci: fix deploy.yml and consolidate workflows
Fix YAML indentation (on:/jobs: were nested under name:), correct
image name from shcizo/myapp to shcizo/pdga-rating, switch registry
auth from GITEA_TOKEN to PACKAGES_TOKEN (auto-injected token lacks
effective registry access), and add packages:write permission.

Remove docker-build.yml since deploy.yml now covers build + push +
deploy in one workflow — previously both triggered on tag push and
raced for the same image tags.
2026-05-22 15:22:41 +02:00
Samuel Enocsson b1e8d63a63 added deploy 2026-05-22 15:15:18 +02:00
Samuel Enocsson e29bc8ee80 feat: show sensitivity bracket around required average
After the binary search converges, also simulate predicted rating at
required±1 average. Display the three rows in the modal so the user can
see how sharp the requirement is — e.g. whether averaging 1 point lower
costs them 1 point of predicted rating or 5.
2026-05-22 13:43:25 +02:00
Samuel Enocsson 96edc606d3 fix: offer refresh button when round history is empty
When a player has rating_history (graph) but no round_history (per-round
detail), calculating a target produced a dead-end error. Now the modal
detects the NO_ROUNDS case and shows a button that triggers the existing
refresh-round-history endpoint and re-runs the calculation on success.
Handles the 24h rate-limit and other refresh errors explicitly.
2026-05-22 13:32:02 +02:00
Samuel Enocsson 1e66b9f94f feat: add target rating calculator (#2) 2026-05-22 13:21:41 +02:00
shcizo c6ac174921 Merge pull request 'fix: preload player rating history to fix first-click chart render (#10)' (#15) from fix/preload-player-history-10 into main 2026-05-22 12:59:26 +02:00
Samuel Enocsson fba1bea247 refactor: address review feedback — extract date helper, rename listener 2026-05-22 11:47:47 +02:00
Samuel Enocsson a63da6f3ca fix: preload player rating history to fix first-click chart render (#10) 2026-05-22 11:41:38 +02:00
shcizo 3cfdc305ec Merge pull request 'fix: header "Next update" uses second Tuesday (closes #12)' (#13) from fix/header-next-update-date into main
Reviewed-on: #13
2026-05-22 10:00:39 +02:00
Samuel Enocsson d336156bbb fix: header "Next update" uses second Tuesday (closes #12)
The topbar showed the first Tuesday of the *next* month instead of
PDGA's actual cycle (second Tuesday of the month). Replace the
duplicated computeNextUpdate() with the central
getNextPDGAUpdateDate() from rating-calculator, keeping only the
formatter ("Tue 9 Jun") here.
2026-05-22 09:54:57 +02:00
Samuel Enocsson e1b9e97484 docs: update CLAUDE.md for Gitea migration and PDGA domain notes
- Fix runtime line (Node 22 slim, not Node 18 Alpine)
- Add Hosting line (Gitea, use tea not gh)
- Reflect new CI/CD flow (Gitea Actions, manual version bump, PACKAGES_TOKEN)
- Add PDGA Domain Notes section (rating cycle, predicted-rating algorithm,
  rate limits) so future sessions don't have to re-derive domain logic
- Note absence of test framework explicitly
2026-05-22 09:35:54 +02:00
Samuel Enocsson ddac06d68f fix(ci): use PACKAGES_TOKEN PAT for docker registry auth
Docker Build / docker (push) Successful in 1m47s
The auto-injected GITEA_TOKEN does not have effective write access to
the container registry on Gitea 1.26 even with `permissions: packages:
write` set. Switching to a dedicated PAT (write:package scope) stored
as the PACKAGES_TOKEN repo secret.

Bumps version to 1.2.3 to trigger a fresh tag-build.
2026-05-22 09:20:46 +02:00
Samuel Enocsson 5a5e45b685 fix(ci): use secrets.GITEA_TOKEN instead of GITHUB_TOKEN
Docker Build / docker (push) Failing after 8s
Per Gitea Actions docs, the auto-injected token is exposed as
GITEA_TOKEN, not GITHUB_TOKEN. The latter is GitHub-Actions-specific
and not aliased on Gitea by default.

Also bumps version to 1.2.2 to trigger a fresh tag-build.
2026-05-22 09:13:06 +02:00
Samuel Enocsson 77f00db037 1.2.1
Docker Build / docker (push) Failing after 2m5s
2026-05-22 08:52:23 +02:00
Samuel Enocsson 50a60b29e7 ci: migrate to Gitea Actions for docker build
Origin moved from GitHub to Gitea. release-please-action and ghcr.io are
GitHub-specific and don't work on Gitea Actions, so the release-please
flow is dropped in favor of manual tag-trigger docker builds.

- Add .gitea/workflows/docker-build.yml: builds and pushes to
  gitea.shcizo.se/shcizo/pdga-rating on tag push (v*)
- Remove release-please-config.json and .release-please-manifest.json
- Neutralize .github/workflows/release-please.yml to workflow_dispatch
  (kept as reference; can't be deleted via tooling under .github/)
2026-05-22 08:50:04 +02:00
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
49 changed files with 9276 additions and 5429 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Build and deploy
on:
push:
tags:
- "v*"
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: gitea.shcizo.se
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
gitea.shcizo.se/shcizo/pdga-rating:${{ gitea.ref_name }}
gitea.shcizo.se/shcizo/pdga-rating:latest
deploy:
runs-on: ubuntu-latest
needs: [build-and-push]
steps:
- uses: https://gitea.shcizo.se/shcizo/package-updater-action@v1
with:
endpoint: https://updater.shcizo.se/update
image: gitea.shcizo.se/shcizo/pdga-rating
tag: ${{ gitea.ref_name }}
token: ${{ secrets.UPDATER_API_KEY }}
+91
View File
@@ -0,0 +1,91 @@
name: Release
# Auto-tagging baserat på conventional commits sedan senaste taggen.
# feat: → minor bump (1.2.9 → 1.3.0)
# fix: → patch bump (1.2.9 → 1.2.10)
# "!:" eller "BREAKING CHANGE" → major bump (1.2.9 → 2.0.0)
# Andra prefix (chore:, docs:, ci:, refactor:, style:, test:) bumpar inte.
# Skippas tyst när inga releaseable commits hittas — t.ex. när workflow:n själv pushar version-commiten.
# Push av tag triggar deploy.yml (PACKAGES_TOKEN är en PAT så cross-workflow-triggers funkar).
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PACKAGES_TOKEN }}
- name: Configure git
run: |
git config user.name "Release Bot"
git config user.email "noreply@shcizo.se"
- name: Determine version bump
id: bump
run: |
set -euo pipefail
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then
RANGE=""
echo "No previous v* tag found — scanning all commits."
else
RANGE="${LAST_TAG}..HEAD"
echo "Scanning commits since ${LAST_TAG}."
fi
BUMP="none"
while IFS= read -r subject; do
[ -z "$subject" ] && continue
if echo "$subject" | grep -qE "^[a-z]+(\([^)]+\))?!:|BREAKING[ -]CHANGE"; then
BUMP="major"
break
elif echo "$subject" | grep -qE "^feat(\([^)]+\))?:"; then
[ "$BUMP" != "major" ] && BUMP="minor"
elif echo "$subject" | grep -qE "^fix(\([^)]+\))?:"; then
[ "$BUMP" = "none" ] && BUMP="patch"
fi
done < <(git log ${RANGE} --no-merges --pretty=format:%s)
echo "Determined bump: ${BUMP}"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
- name: Compute new version
if: steps.bump.outputs.bump != 'none'
id: version
run: |
set -euo pipefail
CURRENT=$(node -p "require('./package.json').version")
IFS=. read -r MAJ MIN PAT <<< "$CURRENT"
case "${{ steps.bump.outputs.bump }}" in
major) NEW="$((MAJ+1)).0.0" ;;
minor) NEW="${MAJ}.$((MIN+1)).0" ;;
patch) NEW="${MAJ}.${MIN}.$((PAT+1))" ;;
esac
echo "Bumping ${CURRENT} → ${NEW}"
echo "new=${NEW}" >> "$GITHUB_OUTPUT"
- name: Update files, commit, tag, push
if: steps.bump.outputs.bump != 'none'
run: |
set -euo pipefail
NEW="${{ steps.version.outputs.new }}"
npm version "$NEW" --no-git-tag-version
git add package.json package-lock.json
git commit -m "$NEW"
git tag "v${NEW}"
git push origin main "v${NEW}"
- name: No-op summary
if: steps.bump.outputs.bump == 'none'
run: echo "No feat/fix/breaking commits since last tag — no release."
+13
View File
@@ -0,0 +1,13 @@
# Deprecated: replaced by .gitea/workflows/docker-build.yml (tag-trigger).
# Origin moved from GitHub to Gitea; release-please-action and ghcr.io are
# GitHub-only and don't run on Gitea Actions. Trigger changed to manual-only
# so neither GitHub nor Gitea Actions will auto-run this file.
name: Release Please (deprecated)
on:
workflow_dispatch:
jobs:
noop:
runs-on: ubuntu-latest
steps:
- run: echo "Deprecated. See .gitea/workflows/docker-build.yml"
+2 -1
View File
@@ -1,4 +1,5 @@
node_modules/
.env
.cache/
*.log
*.log
*.db
+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))
+68
View File
@@ -0,0 +1,68 @@
# PDGA Rating Tracker
PDGA rating scraper and display app. Scrapes player ratings and course data from pdga.com, stores in SQLite, serves via Express with EJS templates and HTMX.
## Tech Stack
- **Runtime:** Node.js 22 (slim/Debian-based in Docker)
- **Hosting:** Gitea (`gitea.shcizo.se/shcizo/pdga-rating`) — use `tea` CLI for issues/PRs, not `gh`
- **Server:** Express with EJS templates
- **Database:** SQLite3 (file: `ratings.db`, Docker: `/app/data/ratings.db`)
- **Frontend:** HTMX + vanilla JS (in `public/js/`)
- **Scraping:** Puppeteer (with stealth plugin) + direct HTTP
- **Logging:** Pino (JSON in production, pino-pretty in dev)
- **CI/CD:** Gitea Actions (tag-triggered build + push + deploy via `.gitea/workflows/deploy.yml`)
## Project Structure
```
server.js # Express app entrypoint
src/
logger.js # Pino logger instance
db.js # SQLite init, migrations, seeding
models/ # Data access (player.js, course.js)
routes/ # Express routes (players, courses, pages)
scrapers/ # PDGA scrapers (HTTP + Puppeteer)
services/ # Business logic (player-service, rating-calculator)
views/
pages/ # EJS page templates
partials/ # EJS partials (shared layout)
public/
css/ # Stylesheets
js/ # Client-side JS (HTMX interactions)
```
## Commands
- `npm start` — Start production server (port 3000)
- `npm run dev` — Start with nodemon (auto-reload)
- `LOG_LEVEL=debug npm start` — Enable debug logging
- `docker compose up` — Run via Docker
**No test framework or lint setup**`package.json` has only `start` and `dev` scripts. If adding either, document it here.
## Conventions
- **Logging:** Use `require('./logger')` (or relative path). Never use `console.log/error` in backend code. Use appropriate Pino levels: `debug` for verbose/diagnostic data, `info` for operational status, `warn` for retries/degraded state, `error` for failures, `fatal` for startup crashes.
- **Frontend JS:** `console.error` is fine in `public/js/` — runs in browser, no Pino.
- **Commits:** Conventional commits (`feat:`, `fix:`, `refactor:`, `chore:`, `ci:`).
- **Releases:** Automated via `.gitea/workflows/release.yml` on push to `main`. Workflow scans conventional commits sedan senaste `v*`-taggen och bumpar strikt semver: `feat:` → minor, `fix:` → patch, `!:`/`BREAKING CHANGE` → major. Andra prefix (`chore:`, `docs:`, `ci:`, `refactor:`, `style:`, `test:`) bumpar inte. Workflow:n uppdaterar `package.json` + `package-lock.json`, committar som `<version>`, taggar `v<version>` och pushar. Tag-pushen triggar `.gitea/workflows/deploy.yml` som (1) bygger + pushar image till `gitea.shcizo.se/shcizo/pdga-rating:<tag>` + `:latest`, sen (2) anropar `package-updater-action` mot `updater.shcizo.se/update`. Required secrets: `PACKAGES_TOKEN` (PAT med `write:package` + repo-push-scope — används av både release.yml för att pusha main/tag och deploy.yml för registry-auth; auto-injicerade `GITEA_TOKEN` triggar inte cross-workflows och saknar effective registry-access) och `UPDATER_API_KEY` (för updater-endpointen). Action-repot `shcizo/package-updater-action` refereras via full Gitea-URL (`https://gitea.shcizo.se/...`) eftersom `uses:` defaultar till GitHub. **Manuell tag** är fortfarande möjlig om release-workflow:n skippas eller behöver kringgås — pusha bara `v<version>`-tag direkt.
- **Scraping:** Two strategies per entity: direct HTTP (fast, preferred) with Puppeteer fallback (stealth plugin for anti-bot). Rate limiting must be respected.
- **Database:** Migrations run automatically on startup in `db.js`. Schema changes go there.
- **Templates:** EJS with shared layout in `views/partials/`. Pages use HTMX for dynamic content loading.
## PDGA Domain Notes
- **Rating publication cycle:** PDGA officially recalculates ratings on the **second Tuesday of each month**. `getNextPDGAUpdateDate()` in `src/services/rating-calculator.js` computes this — round filtering uses it as cutoff.
- **Predicted rating algorithm:** `calculatePredictedRating(roundRatings)` replicates PDGA's formula — 12-mo window (expands to 24 if <8 rounds), outlier removal at ≥7 rounds (2.5σ + 100pt threshold), double-weighting of recent 25% at ≥9 rounds. Returns `{rating, stdDev, debugLog}`.
- **Rate limits:** `POST /api/refresh-round-history/:pdgaNumber` enforces a 24h cooldown per player (`src/routes/players.js`). Don't bypass — PDGA's site rate-limits aggressively.
- **Round history refresh** uses Puppeteer (stealth plugin), other scraping prefers direct HTTP. Predicted rating is recomputed and stored on each refresh.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `LOG_LEVEL` | `info` | Pino log level |
| `NODE_ENV` | — | Set to `production` for JSON logs |
| `DB_PATH` | `./ratings.db` | SQLite database path |
| `PUPPETEER_EXECUTABLE_PATH` | — | Chromium path (set in Docker) |
+10 -12
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.4.1",
"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"
}
}
+462
View File
@@ -0,0 +1,462 @@
/* ═══════════════════════════════════════════════════
Courses Page
═══════════════════════════════════════════════════ */
/* ── Action Card (tabs + inputs) ─────────────────── */
.action-card {
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
margin-bottom: 16px;
}
.action-card-tabs {
display: flex;
border-bottom: 1px solid var(--line-2);
}
.action-tab {
padding: 12px 18px;
background: transparent;
border: 0;
color: var(--ink-2);
font: 600 14px/1.2 var(--font-sans);
cursor: pointer;
transition: color 120ms;
}
.action-tab:hover {
color: var(--ink);
}
.action-tab.is-active {
color: var(--ink);
box-shadow: inset 0 -2px 0 var(--accent);
}
.action-card-body {
padding: 16px 20px;
}
.action-pane[hidden] {
display: none;
}
.action-card-body input[type=text] {
width: 100%;
height: 40px;
padding: 0 14px;
border: 1px solid var(--line-2);
border-radius: var(--radius-sm);
background: var(--paper-2);
font: 14px/1.2 var(--font-sans);
}
.action-card-body input[type=text]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px color-mix(in oklch, var(--accent) 14%, transparent);
}
.action-hint {
margin: 8px 0 0;
font-size: 11.5px;
color: var(--ink-3);
}
.tjing-search-row {
display: flex;
gap: 10px;
align-items: center;
}
.tjing-search-row input[type=text] {
flex: 1;
}
/* ── Buttons ──────────────────────────────────────── */
/* .btn-primary is defined in shared.css — no override needed here */
.btn-pill {
padding: 6px 12px;
background: var(--accent);
color: #fff;
border: 0;
border-radius: 999px;
font: 600 12.5px/1 var(--font-sans);
cursor: pointer;
height: 28px;
white-space: nowrap;
}
.btn-pill:disabled {
opacity: .6;
cursor: default;
}
/* ── Results bar ─────────────────────────────────── */
.results-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 4px;
font-variant-numeric: tabular-nums;
}
.results-count {
color: var(--ink-2);
font-size: 13px;
}
.results-count strong {
color: var(--ink);
font-weight: 600;
}
.results-link {
color: var(--accent);
text-decoration: none;
font: 500 13.5px/1.2 var(--font-sans);
}
/* ── Course grid ─────────────────────────────────── */
.course-grid {
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.course-row {
display: grid;
grid-template-columns: minmax(280px, 2fr) minmax(140px, 1fr) minmax(140px, 0.9fr) 72px;
align-items: center;
gap: 14px;
padding: 14px 20px;
border-bottom: 1px solid var(--line-2);
cursor: pointer;
transition: background 120ms;
}
.course-row:hover {
background: var(--paper-2);
}
.course-row.row-open {
background: var(--paper-2);
box-shadow: inset 3px 0 0 var(--accent);
}
.course-row[hidden],
.expanded-content[hidden] {
display: none;
}
.course-row.row-open .icon-chev i {
transform: rotate(180deg);
}
.course-row--header {
height: 48px;
padding: 0 20px;
background: var(--paper-2);
border-bottom: 1px solid var(--line);
cursor: default;
}
.course-row--header:hover {
background: var(--paper-2);
}
.course-header-cell {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-3);
}
.course-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.course-name {
font-weight: 600;
font-size: 14px;
letter-spacing: -0.005em;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.course-row:hover .course-name,
.course-row.row-open .course-name {
color: var(--accent);
}
.course-meta {
font-size: 11px;
color: var(--ink-3);
}
.course-city {
font-size: 14px;
color: var(--ink);
}
.course-updated {
font-family: var(--font-mono);
font-feature-settings: "tnum", "zero";
font-size: 12.5px;
color: var(--ink-3);
}
.course-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
/* ── Expanded layout panel ───────────────────────── */
.expanded-content {
display: none;
}
.expanded-content.is-open {
display: block;
animation: expandIn .2s ease;
}
.expanded-cell {
padding: 22px 28px 28px;
background: color-mix(in oklch, var(--accent) 4%, var(--paper-2));
border-bottom: 1px solid var(--line-2);
}
/* ── Layout list ─────────────────────────────────── */
.layouts-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.layouts-kicker {
font: 600 11px/1 var(--font-sans);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
}
.layouts-count {
font-size: 12.5px;
color: var(--ink-3);
}
.layout-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.layout-card {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 14px;
padding: 12px 18px;
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: var(--radius-sm);
transition: border-color 120ms, box-shadow 120ms;
}
.layout-card:hover {
border-color: var(--line);
box-shadow: var(--shadow-card);
}
.layout-card--inactive {
background: transparent;
border-style: dashed;
opacity: 0.65;
}
.layout-card--inactive .layout-name {
color: var(--ink-2);
font-weight: 500;
}
.layout-info {
display: flex;
align-items: baseline;
gap: 14px;
min-width: 0;
}
.layout-name {
font: 600 13.5px/1.2 var(--font-sans);
color: var(--ink);
}
.layout-last-played {
font: 12.5px/1 var(--font-mono);
color: var(--ink-3);
}
.layout-never-played {
font-size: 12.5px;
color: var(--down);
}
.layout-chips {
display: flex;
gap: 14px;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 8px;
font: 600 12.5px/1 var(--font-mono);
font-variant-numeric: tabular-nums;
}
.chip-par {
color: var(--accent);
background: color-mix(in oklch, var(--accent) 8%, transparent);
}
.chip-rating--green {
color: var(--rating-tier-high);
background: color-mix(in oklch, var(--rating-tier-high) 10%, transparent);
}
.chip-rating--amber {
color: var(--rating-tier-mid);
background: color-mix(in oklch, var(--rating-tier-mid) 10%, transparent);
}
.chip-rating--orange {
color: var(--rating-tier-low);
background: color-mix(in oklch, var(--rating-tier-low) 10%, transparent);
}
/* ── Inactive layouts collapsible ────────────────── */
.inactive-layouts {
margin-top: 14px;
background: var(--paper-2);
border: 1px solid var(--line-2);
border-radius: var(--radius-sm);
overflow: hidden;
}
.inactive-toggle {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 12px 16px;
background: transparent;
border: 0;
font: 500 13.5px/1.2 var(--font-sans);
color: var(--ink-2);
cursor: pointer;
}
.inactive-toggle .icon-chev {
transition: transform 180ms;
display: inline-block;
}
.inactive-toggle.is-open .icon-chev {
transform: rotate(180deg);
}
.inactive-layouts-body {
padding: 0 12px 12px;
}
.inactive-layouts-body[hidden] {
display: none;
}
/* ── Tjing results ───────────────────────────────── */
#tjing-results {
margin-top: 12px;
}
.tjing-result {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--line-2);
}
.tjing-result:last-child {
border-bottom: 0;
}
.tjing-result-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tjing-result-name {
font: 600 14px/1.3 var(--font-sans);
color: var(--ink);
}
.tjing-result-address {
font-size: 12.5px;
color: var(--ink-3);
}
.tjing-error {
color: var(--down);
font-size: 13px;
padding: 12px 0;
}
/* ── No-layouts message ──────────────────────────── */
.no-layouts {
text-align: center;
color: var(--ink-3);
font-style: italic;
padding: 24px;
font-size: 13px;
}
/* ── Loading placeholder ─────────────────────────── */
.loading {
text-align: center;
color: var(--ink-3);
font-size: 13px;
padding: 24px;
}
+639
View File
@@ -0,0 +1,639 @@
/* ═══════════════════════════════════════════════════
PDGA Ratings — Mobile Styles (≤ 880px)
All rules scoped inside @media (max-width: 880px)
unless marked "default hidden" (for elements that
must be display:none on desktop too).
═══════════════════════════════════════════════════ */
/* ── Default-hidden mobile elements ──────────────── */
/* Hidden on ALL viewports; mobile.css un-hides them */
.topbar__mobile { display: none; }
.mobile-list { display: none; }
.mobile-add-bar { display: none; }
.mobile-section-head { display: none; }
.m-tab-pill { display: none; }
.m-search-input { display: none; }
/* ═══════════════════════════════════════════════════ */
@media (max-width: 880px) {
/* ── Desktop elements → hide on mobile ─────────── */
.topbar__inner { display: none !important; }
.kpi-strip { display: none !important; }
.add-bar { display: none !important; }
.footnote { display: none !important; }
.table-toolbar { display: none !important; }
/* Hide desktop search card on mobile (mobile has .m-search-input instead) */
.card-section { display: none; }
/* ── Mobile course search input ─────────────────── */
.m-search-input {
display: block;
width: 100%;
height: 38px;
padding: 0 12px;
background: var(--paper-2);
border: 1px solid var(--line-2);
border-radius: 10px;
font-family: var(--font-sans);
font-size: 14px;
color: var(--ink);
outline: none;
box-sizing: border-box;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.m-search-input::placeholder {
color: var(--ink-3);
opacity: 0.7;
}
.m-search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 14%, transparent);
}
/* Hide desktop table but keep .table-card as wrapper */
.table-card > #ratings-table table,
#ratings-table table,
#courses-table table {
display: none;
}
/* ── Container ──────────────────────────────────── */
.container {
--m-container-pad-bottom: 80px;
padding: 10px 12px var(--m-container-pad-bottom);
gap: 12px;
}
/* ── Topbar mobile ──────────────────────────────── */
.topbar__mobile {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
}
.topbar__mobile-row1 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 8px;
gap: 10px;
}
.topbar__mobile-brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.topbar__mobile-mark {
width: 26px;
height: 26px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 70%, black));
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.topbar__mobile-mark svg {
width: 16px;
height: 16px;
}
.topbar__mobile-brand-text {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.topbar__mobile-title {
font-size: 13px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--ink);
}
.topbar__mobile-sub {
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ink-3);
}
.topbar__mobile-refresh {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--paper);
color: var(--ink);
cursor: pointer;
font-size: 16px;
font-family: var(--font-sans);
transition: background 120ms ease, border-color 120ms ease;
flex-shrink: 0;
padding: 0;
}
.topbar__mobile-refresh:hover:not(:disabled) {
background: var(--hover);
border-color: color-mix(in oklab, var(--line) 60%, var(--ink-3));
}
.topbar__mobile-refresh:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.topbar__mobile-refresh .topbar__refresh-spinner {
display: none;
width: 14px;
height: 14px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: topbar-spin 0.7s linear infinite;
}
.topbar__mobile-refresh.htmx-request .topbar__refresh-spinner {
display: inline-block;
}
.topbar__mobile-refresh.htmx-request .topbar__refresh-icon {
display: none;
}
.topbar__mobile-row2 {
padding: 0 16px 10px;
}
.topbar__mobile-nav {
display: flex;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 4px;
gap: 2px;
}
.topbar__mobile-nav a {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
height: 28px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
text-decoration: none;
transition: color 120ms ease, background 120ms ease;
}
.topbar__mobile-nav a:hover {
color: var(--ink);
}
.topbar__mobile-nav a.active {
background: var(--paper);
color: var(--ink);
box-shadow: var(--shadow-card);
}
/* ── Mobile section head ────────────────────────── */
.mobile-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0 2px;
}
/* pill-button for "Trend chart" toggle in mobile section head */
.pill-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--line-2);
background: var(--paper-2);
color: var(--ink-2);
font-size: 11px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.pill-button:hover {
background: var(--hover);
border-color: var(--line);
}
.pill-button[aria-pressed="true"] {
background: color-mix(in oklab, var(--accent) 10%, white);
border-color: color-mix(in oklab, var(--accent) 35%, var(--line-2));
color: var(--accent-text);
}
/* ── Mobile list wrapper ────────────────────────── */
.mobile-list {
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 90px;
}
/* ── Player card ────────────────────────────────── */
.m-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
box-shadow: var(--shadow-card);
cursor: pointer;
transition: background 120ms ease;
}
.m-card:hover {
background: var(--hover);
}
.m-card__head {
display: flex;
align-items: center;
gap: 10px;
}
.m-rank-chip {
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--paper-2);
border: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--ink-3);
flex-shrink: 0;
}
.m-rank-chip--first {
background: var(--accent-soft);
color: var(--accent-text);
border-color: color-mix(in oklab, var(--accent) 25%, transparent);
}
.m-card__name-stack {
display: flex;
flex-direction: column;
gap: 1px;
flex: 1;
min-width: 0;
}
.m-player-name {
font-size: 14px;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.005em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-pdga-num {
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-3);
}
.m-chevron {
color: var(--ink-3);
font-size: 14px;
flex-shrink: 0;
transition: transform 180ms ease;
line-height: 1;
}
.m-card.is-open .m-chevron {
transform: rotate(180deg);
}
.m-card__body {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
margin-top: 8px;
}
.m-card__stats {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.m-stat-row {
display: flex;
align-items: center;
gap: 8px;
}
.m-stat-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-3);
width: 62px;
flex-shrink: 0;
}
.m-num {
font-family: var(--font-mono);
font-feature-settings: "tnum", "zero";
font-size: 18px;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.02em;
line-height: 1;
}
.m-num--predicted {
color: var(--ink-2);
}
/* Override delta-pill size inside mobile cards */
.m-card .delta-pill {
font-size: 11px;
padding: 2px 7px 2px 5px;
}
.m-card__sparkline {
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
.m-chart-spark {
display: block;
}
/* ── Player card expand panel ───────────────────── */
.m-card__expand {
display: none;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.m-card.is-open .m-card__expand {
display: block;
}
.m-chart {
width: 100%;
max-width: 480px;
}
.m-detail-grid {
display: grid;
grid-template-columns: 1fr;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.m-detail-grid > div {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 6px 0;
border-bottom: 1px dashed var(--line);
}
.m-detail-grid > div:last-child {
border-bottom: none;
}
.m-detail-grid dt {
color: var(--ink-2);
font-size: 12px;
font-weight: 400;
flex-shrink: 0;
}
.m-detail-grid dd {
color: var(--ink);
font-size: 12px;
font-family: var(--font-mono);
font-feature-settings: "tnum", "zero";
margin: 0;
text-align: right;
}
/* ── Courses mobile tab pill ────────────────────── */
.m-tab-pill {
display: flex;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 4px;
gap: 2px;
margin-bottom: 4px;
}
.m-tab-pill__btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
height: 30px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-sans);
color: var(--ink-2);
background: transparent;
border: none;
cursor: pointer;
transition: color 120ms ease, background 120ms ease;
}
.m-tab-pill__btn--active {
background: var(--paper);
color: var(--ink);
box-shadow: var(--shadow-card);
}
.m-tab-pill__btn--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ── Course card ────────────────────────────────── */
.m-course-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
box-shadow: var(--shadow-card);
cursor: pointer;
transition: background 120ms ease;
}
.m-course-card:hover {
background: var(--hover);
}
.m-course-card__head {
display: flex;
align-items: center;
gap: 10px;
}
.m-course-card__name-stack {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.m-course-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.m-course-name {
font-size: 14px;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.005em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-layouts-pill {
font-family: var(--font-mono);
font-size: 11px;
padding: 2px 7px;
border-radius: 999px;
background: var(--paper-2);
border: 1px solid var(--line-2);
color: var(--ink-3);
white-space: nowrap;
flex-shrink: 0;
}
.m-course-card__meta {
font-size: 12px;
color: var(--ink-3);
margin-top: 2px;
}
.m-course-card__expand {
display: none;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.m-course-card.is-open .m-course-card__expand {
display: block;
}
.m-course-card.is-open .m-chevron {
transform: rotate(180deg);
}
/* ── Sticky mobile add-bar ──────────────────────── */
.mobile-add-bar {
display: flex;
gap: 8px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom)) 12px;
background: color-mix(in oklab, var(--paper) 88%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-top: 1px solid var(--line);
position: sticky;
bottom: 0;
z-index: 10;
/* Negative margin to break out of container padding */
margin-left: -12px;
margin-right: -12px;
margin-bottom: calc(-1 * var(--m-container-pad-bottom));
}
.mobile-add-bar input {
flex: 1;
height: 38px;
background: var(--paper-2);
border: 1px solid var(--line-2);
border-radius: 10px;
padding: 0 14px;
font-family: var(--font-mono);
font-size: 14px;
color: var(--ink);
font-feature-settings: "tnum";
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.mobile-add-bar input::placeholder {
color: var(--ink-3);
opacity: 0.7;
}
.mobile-add-bar input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 14%, transparent);
}
.mobile-add-bar .btn-primary {
height: 38px;
padding: 0 18px;
flex-shrink: 0;
}
/* ── Table-card: remove overflow:hidden on mobile ─ */
/* so sticky add-bar can extend to bottom */
.table-card {
overflow: visible;
}
} /* end @media (max-width: 880px) */
+266
View File
@@ -0,0 +1,266 @@
/* ═══════════════════════════════════════════════════
Players Page
═══════════════════════════════════════════════════ */
/* ── (Add player now uses .btn-primary from shared.css) ── */
/* ── Mobile helpers ───────────────────────────── */
.mobile-only {
display: none;
}
/* ── 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);
}
/* ── 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;
}
/* ── Target Rating Calculator ─────────────────── */
.target-rating-icon {
color: var(--accent);
}
#target-rating-form .form-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
#target-rating-form .form-row label {
font-size: 0.9em;
}
#target-rating-form .form-row input {
padding: 6px 8px;
}
#target-rating-form .form-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.target-rating-result {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.target-rating-result .target-summary div {
margin-bottom: 6px;
}
.target-rating-result .warning {
color: var(--down);
margin-top: 8px;
}
.target-rating-result .error {
color: var(--red);
}
.target-rating-result .muted {
color: var(--text-muted);
font-size: 0.9em;
}
.target-rating-result .loading {
color: var(--text-muted);
}
.target-rating-result .no-history-prompt {
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.target-rating-result .sensitivity {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--border);
}
.target-rating-result .sensitivity-heading {
font-size: 0.9em;
color: var(--text-muted);
margin-bottom: 4px;
}
.target-rating-result .sensitivity-row {
font-variant-numeric: tabular-nums;
padding-left: 12px;
}
.target-rating-result .sensitivity-row.is-target {
font-weight: 600;
}
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
function createRatingChart(container, history, opts) {
opts = opts || {};
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 = opts.w || 880;
var H = opts.h || 240;
var pad = opts.padding || { left: 44, right: 16, top: 20, bottom: 32 };
var tickCount = opts.tickCount !== undefined ? opts.tickCount : 4;
var xLabelCount = opts.xLabelCount !== undefined ? opts.xLabelCount : 5;
var dotR = opts.dotR !== undefined ? opts.dotR : 3;
var lastDotR = opts.lastDotR !== undefined ? opts.lastDotR : 4;
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
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: String(lastDotR), 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: String(dotR), fill: 'var(--accent)'
}));
}
});
// X-axis labels (evenly spaced)
var labelCount = Math.min(xLabelCount, 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);
}
+340
View File
@@ -0,0 +1,340 @@
// ── Tab switching ──────────────────────────────────
function initCourseTabs() {
const tabs = document.querySelectorAll('.action-tab');
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) {
t.classList.remove('is-active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('is-active');
tab.setAttribute('aria-selected', 'true');
document.querySelectorAll('.action-pane').forEach(function(pane) {
pane.hidden = true;
pane.classList.remove('is-active');
});
const targetId = 'tab-pane-' + tab.dataset.tab;
const pane = document.getElementById(targetId);
if (pane) {
pane.hidden = false;
pane.classList.add('is-active');
}
});
});
}
// ── Live filter ────────────────────────────────────
function initCourseLiveFilter() {
const input = document.getElementById('course-filter-input');
if (!input) return;
input.addEventListener('input', function() {
const q = input.value.toLowerCase().trim();
const rows = document.querySelectorAll('.course-row');
let visible = 0;
rows.forEach(function(row) {
const name = row.dataset.courseName || '';
const city = row.dataset.courseCity || '';
const match = !q || name.includes(q) || city.includes(q);
row.hidden = !match;
// Keep the expanded content sibling in sync
const next = row.nextElementSibling;
if (next && next.classList.contains('expanded-content')) {
next.hidden = !match;
}
if (match) visible++;
});
const visibleEl = document.getElementById('visible-count');
if (visibleEl) visibleEl.textContent = visible;
});
}
// ── Count display ──────────────────────────────────
function initCourseCounts() {
const grid = document.querySelector('.course-grid');
const total = grid ? parseInt(grid.dataset.totalCount || '0', 10) : 0;
const rows = document.querySelectorAll('.course-row');
let visible = 0;
rows.forEach(function(r) { if (!r.hidden) visible++; });
const totalEl = document.getElementById('total-count');
const visibleEl = document.getElementById('visible-count');
if (totalEl) totalEl.textContent = total;
if (visibleEl) visibleEl.textContent = visible || total;
}
// ── Course row expand/collapse ─────────────────────
function toggleCourseLayouts(courseId) {
const row = document.querySelector('.course-row[data-course-id="' + courseId + '"]');
const content = document.getElementById('course-layouts-' + courseId);
if (!row || !content) return;
const isOpen = content.classList.contains('is-open');
if (isOpen) {
content.classList.remove('is-open');
row.classList.remove('row-open');
} else {
content.classList.add('is-open');
row.classList.add('row-open');
// Lazy-load layouts on first expand
const cell = content.querySelector('.expanded-cell');
if (cell && cell.dataset.loaded !== 'true') {
cell.dataset.loaded = 'true';
htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: cell, swap: 'innerHTML' });
}
}
}
// ── Mobile course card toggle ──────────────────────
let openMobileCourseId = null;
function toggleMobileCourseLayouts(courseId) {
const card = document.getElementById('m-course-' + courseId);
if (!card) return;
const isOpen = card.classList.contains('is-open');
// Close previously open card
if (openMobileCourseId !== null && openMobileCourseId !== courseId) {
const prevCard = document.getElementById('m-course-' + openMobileCourseId);
if (prevCard) {
prevCard.classList.remove('is-open');
prevCard.setAttribute('aria-expanded', 'false');
}
openMobileCourseId = null;
}
if (isOpen) {
card.classList.remove('is-open');
card.setAttribute('aria-expanded', 'false');
openMobileCourseId = null;
return;
}
card.classList.add('is-open');
card.setAttribute('aria-expanded', 'true');
openMobileCourseId = courseId;
// Lazy-load layouts on first expand
const container = document.getElementById('m-layouts-container-' + courseId);
if (container && container.dataset.loaded !== 'true') {
htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: '#m-layouts-container-' + courseId, swap: 'innerHTML' });
container.dataset.loaded = 'true';
}
}
// ── Inactive layouts toggle ────────────────────────
function toggleInactiveLayouts(btn) {
const body = btn.nextElementSibling;
if (!body) return;
const isOpen = btn.classList.contains('is-open');
btn.classList.toggle('is-open', !isOpen);
btn.setAttribute('aria-expanded', String(!isOpen));
body.hidden = isOpen;
}
// ── Scrape courses ─────────────────────────────────
async function scrapeCourses() {
const btn = document.getElementById('scrape-courses-btn');
if (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.trigger(document.body, 'refresh');
} else {
alert('Failed to scrape courses');
}
} catch (error) {
console.error('Error scraping courses:', error);
alert('Error scraping courses');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = 'Scrape Courses';
}
}
}
// ── Scrape layouts for a course ────────────────────
async function scrapeLayouts(courseId, btn) {
if (btn) btn.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) {
// Reload expanded layout content if currently open
const content = document.getElementById('course-layouts-' + courseId);
if (content && content.classList.contains('is-open')) {
const cell = content.querySelector('.expanded-cell');
if (cell) {
cell.dataset.loaded = 'true';
htmx.ajax('GET', '/partials/course-layouts/' + courseId, { target: cell, swap: 'innerHTML' });
}
}
alert(data.message);
} else {
alert('Failed to scrape layouts');
}
} catch (error) {
console.error('Error scraping layouts:', error);
alert('Error scraping layouts');
} finally {
if (btn) btn.classList.remove('spinning');
}
}
// ── Tjing search ───────────────────────────────────
async function searchTjing() {
const input = document.getElementById('tjing-search-input');
const btn = document.getElementById('tjing-search-btn');
const container = document.getElementById('tjing-results');
if (!input || !container) return;
const q = input.value.trim();
if (!q) return;
btn.disabled = true;
// Clear previous results safely
while (container.firstChild) {
container.removeChild(container.firstChild);
}
try {
const response = await fetch('/api/tjing/search?q=' + encodeURIComponent(q));
let data;
try {
data = await response.json();
} catch (e) {
const errP = document.createElement('p');
errP.className = 'tjing-error';
errP.textContent = 'Invalid response from server.';
container.appendChild(errP);
return;
}
if (!response.ok || data.error) {
const errP2 = document.createElement('p');
errP2.className = 'tjing-error';
errP2.textContent = 'Error: ' + (data.error || 'Search failed');
container.appendChild(errP2);
return;
}
const results = data.results || [];
if (results.length === 0) {
const noResults = document.createElement('p');
noResults.className = 'tjing-error';
noResults.textContent = 'No courses found on Tjing.';
container.appendChild(noResults);
return;
}
results.forEach(function(course) {
const item = document.createElement('div');
item.className = 'tjing-result';
const info = document.createElement('div');
info.className = 'tjing-result-info';
const nameSpan = document.createElement('span');
nameSpan.className = 'tjing-result-name';
nameSpan.textContent = course.name || '';
const addrSpan = document.createElement('span');
addrSpan.className = 'tjing-result-address';
addrSpan.textContent = course.address || '';
info.appendChild(nameSpan);
info.appendChild(addrSpan);
const importBtn = document.createElement('button');
importBtn.className = 'btn-pill';
importBtn.textContent = 'Import';
(function(id, b) {
b.addEventListener('click', function() { importFromTjing(id, b); });
})(course.id, importBtn);
item.appendChild(info);
item.appendChild(importBtn);
container.appendChild(item);
});
} catch (error) {
console.error('Error searching Tjing:', error);
const errFallback = document.createElement('p');
errFallback.className = 'tjing-error';
errFallback.textContent = 'Failed to search Tjing.';
container.appendChild(errFallback);
} finally {
btn.disabled = false;
}
}
// ── Tjing import ───────────────────────────────────
async function importFromTjing(tjingId, btn) {
btn.disabled = true;
btn.textContent = 'Importing…';
try {
const response = await fetch('/api/tjing/import/' + encodeURIComponent(tjingId), { method: 'POST' });
let data;
try {
data = await response.json();
} catch (e) {
btn.textContent = 'Error';
btn.disabled = false;
return;
}
if (!response.ok || data.error) {
btn.textContent = 'Error: ' + (data.error || 'Import failed');
btn.disabled = false;
return;
}
btn.textContent = 'Imported ✓';
// Trigger table reload
htmx.trigger(document.body, 'refresh');
} catch (error) {
console.error('Error importing from Tjing:', error);
btn.textContent = 'Failed';
btn.disabled = false;
}
}
// ── Init ───────────────────────────────────────────
function initAll() {
initCourseTabs();
initCourseLiveFilter();
initCourseCounts();
}
document.addEventListener('DOMContentLoaded', initAll);
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail && evt.detail.target && evt.detail.target.id === 'course-table-region') {
initCourseLiveFilter();
initCourseCounts();
}
});
+765
View File
@@ -0,0 +1,765 @@
let pendingPlayerData = null;
let openPdgaNumber = null;
// ── Delta-pill helper ─────────────────────────────
function renderDeltaPill(value, extraClass) {
const isNull = (value == null);
const cls = isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat';
const glyph = (isNull || value === 0) ? '' : value > 0 ? '▲' : '▼';
const num = isNull ? '—' : value > 0 ? '+' + value : String(value);
return { glyph, num, cls };
}
function applyDeltaPill(pillEl, value) {
if (!pillEl) return;
const pill = renderDeltaPill(value);
pillEl.className = 'delta-pill ' + pill.cls;
while (pillEl.firstChild) pillEl.removeChild(pillEl.firstChild);
const glyphSpan = document.createElement('span');
glyphSpan.className = 'delta-glyph';
glyphSpan.textContent = pill.glyph;
const numSpan = document.createElement('span');
numSpan.className = 'delta-num';
numSpan.textContent = pill.num;
pillEl.appendChild(glyphSpan);
pillEl.appendChild(numSpan);
}
function initChartsIn(rootEl) {
rootEl.querySelectorAll('.player-chart').forEach(function(container) {
if (container.dataset.charted === 'true') return;
if (!container.dataset.history) return;
try {
const history = JSON.parse(container.dataset.history);
const isMobile = container.dataset.variant === 'mobile';
if (isMobile) {
createRatingChart(container, history, {
w: 360,
h: 160,
padding: { left: 36, right: 12, top: 14, bottom: 24 },
tickCount: 3,
xLabelCount: 3,
dotR: 2,
lastDotR: 3
});
} else {
createRatingChart(container, history);
}
container.dataset.charted = 'true';
} catch (e) {
console.error('Error rendering chart:', e);
}
});
}
function setupAfterTableSwap() {
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail.target;
if (target.id === 'ratings-table') {
initChartsIn(target);
return;
}
if (target.id && target.id.startsWith('history-content-')) {
initChartsIn(target);
}
});
}
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 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) {
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 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;
}
}
} 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 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 SPARKLINE_KEY = 'ratingtracker.sparklines';
function syncSparklineButtons(state) {
const btns = document.querySelectorAll('#trendchart-toggle, #trendchart-toggle-mobile');
btns.forEach(function(b) {
b.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false');
});
}
const state = localStorage.getItem(SPARKLINE_KEY) || 'on';
document.body.dataset.sparklines = state;
syncSparklineButtons(state);
document.body.addEventListener('click', function(e) {
const target = e.target.closest('#trendchart-toggle, #trendchart-toggle-mobile');
if (!target) return;
const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on';
document.body.dataset.sparklines = next;
localStorage.setItem(SPARKLINE_KEY, next);
syncSparklineButtons(next);
});
// Re-sync after HTMX table swap (mobile button is inside the swapped partial)
document.body.addEventListener('htmx:afterSwap', function(event) {
const target = event.detail.target;
if (target.id === 'ratings-table') {
syncSparklineButtons(document.body.dataset.sparklines || 'on');
}
});
});
// ── 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));
});
// ── Target Rating Calculator ───────────────────────
function openTargetRatingModal(pdgaNumber) {
const modal = document.getElementById('target-rating-modal');
const header = document.getElementById('target-rating-modal-header');
const pdgaField = document.getElementById('target-rating-pdga');
const result = document.getElementById('target-rating-result');
const targetInput = document.getElementById('target-rating-input');
const roundsInput = document.getElementById('target-rounds-input');
const submitBtn = document.getElementById('target-rating-submit');
const playerNameEl = document.querySelector('#row-' + pdgaNumber + ' .player-name a');
const playerName = playerNameEl ? playerNameEl.textContent : 'PDGA #' + pdgaNumber;
header.textContent = 'Calculate Target Rating — ' + playerName;
pdgaField.value = pdgaNumber;
result.style.display = 'none';
while (result.firstChild) result.removeChild(result.firstChild);
targetInput.value = '';
roundsInput.value = '4';
submitBtn.disabled = false;
submitBtn.textContent = 'Calculate';
modal.style.display = 'flex';
targetInput.focus();
}
function _targetResultMsg(parent, cls, text) {
const d = document.createElement('div');
d.className = cls;
d.textContent = text;
parent.appendChild(d);
}
async function calculateTargetRating(event) {
if (event) event.preventDefault();
const pdgaNumber = document.getElementById('target-rating-pdga').value;
const targetRating = parseInt(document.getElementById('target-rating-input').value, 10);
const rounds = parseInt(document.getElementById('target-rounds-input').value, 10);
const result = document.getElementById('target-rating-result');
const submitBtn = document.getElementById('target-rating-submit');
function clearResult() {
while (result.firstChild) result.removeChild(result.firstChild);
}
if (!Number.isInteger(targetRating) || targetRating < 400 || targetRating > 1200) {
result.style.display = 'block';
clearResult();
_targetResultMsg(result, 'error', 'Target rating must be 400-1200.');
return;
}
if (!Number.isInteger(rounds) || rounds < 1 || rounds > 20) {
result.style.display = 'block';
clearResult();
_targetResultMsg(result, 'error', 'Rounds must be an integer 1-20.');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Calculating...';
result.style.display = 'block';
clearResult();
_targetResultMsg(result, 'loading', 'Calculating...');
try {
const response = await fetch('/api/calculate-target-rating/' + pdgaNumber, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetRating: targetRating, rounds: rounds })
});
const data = await response.json();
clearResult();
if (!response.ok || !data.success) {
if (response.status === 404 && data.errorType === 'NO_ROUNDS') {
renderNoHistoryPrompt(pdgaNumber, result);
return;
}
const msg = data.details ? data.error + ': ' + data.details : (data.error || 'Calculation failed');
_targetResultMsg(result, 'error', msg);
return;
}
const summary = document.createElement('div');
summary.className = 'target-summary';
const avgLine = document.createElement('div');
const avgStrong = document.createElement('strong');
avgStrong.textContent = 'Required round average: ';
avgLine.appendChild(avgStrong);
avgLine.appendChild(document.createTextNode(String(data.requiredAverage)));
summary.appendChild(avgLine);
const currentLine = document.createElement('div');
currentLine.textContent = 'Current predicted rating: ' + data.currentRating;
summary.appendChild(currentLine);
const simLine = document.createElement('div');
simLine.textContent = 'Simulated predicted rating at this average: ' + data.predictedRating;
summary.appendChild(simLine);
const mutedLine = document.createElement('div');
mutedLine.className = 'muted';
mutedLine.textContent = 'Across ' + data.rounds + ' synthetic rounds before the next PDGA update.';
summary.appendChild(mutedLine);
if (data.sensitivity) {
renderSensitivity(data.sensitivity, summary);
}
if (data.warning) {
_targetResultMsg(summary, 'warning', data.warning);
}
result.appendChild(summary);
} catch (err) {
console.error('Error calculating target rating:', err);
clearResult();
_targetResultMsg(result, 'error', 'Network error. Please try again.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Calculate';
}
}
function closeTargetRatingModal(event) {
document.getElementById('target-rating-modal').style.display = 'none';
}
function renderSensitivity(sensitivity, container) {
const wrapper = document.createElement('div');
wrapper.className = 'sensitivity';
const heading = document.createElement('div');
heading.className = 'sensitivity-heading';
heading.textContent = 'Sensitivity:';
wrapper.appendChild(heading);
const rows = [
{ row: sensitivity.lower, isTarget: false },
{ row: sensitivity.target, isTarget: true },
{ row: sensitivity.upper, isTarget: false }
];
for (const { row, isTarget } of rows) {
const line = document.createElement('div');
line.className = 'sensitivity-row' + (isTarget ? ' is-target' : '');
line.textContent = 'Average ' + row.average + ' → predicted ' + row.predicted + (isTarget ? ' (target)' : '');
wrapper.appendChild(line);
}
container.appendChild(wrapper);
}
function renderNoHistoryPrompt(pdgaNumber, container) {
const wrapper = document.createElement('div');
wrapper.className = 'no-history-prompt';
const msg = document.createElement('div');
msg.textContent = 'No round-level history is stored for this player yet. Refresh from PDGA to enable the calculation.';
wrapper.appendChild(msg);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-confirm';
btn.textContent = 'Refresh round history & calculate';
btn.addEventListener('click', function () { refreshHistoryThenCalculate(pdgaNumber); });
wrapper.appendChild(btn);
container.appendChild(wrapper);
}
async function refreshHistoryThenCalculate(pdgaNumber) {
const result = document.getElementById('target-rating-result');
while (result.firstChild) result.removeChild(result.firstChild);
_targetResultMsg(result, 'loading', 'Refreshing round history from PDGA — this may take up to 30 seconds...');
try {
const response = await fetch('/api/refresh-round-history/' + pdgaNumber, { method: 'POST' });
const data = await response.json();
while (result.firstChild) result.removeChild(result.firstChild);
if (response.status === 429) {
const hours = data.hoursRemaining ? data.hoursRemaining + ' hour(s)' : 'a while';
_targetResultMsg(result, 'error', 'Round history was refreshed recently. Try again in ' + hours + '.');
return;
}
if (!response.ok || !data.success) {
const msg = data.details ? data.error + ': ' + data.details : (data.error || 'Refresh failed');
_targetResultMsg(result, 'error', msg);
return;
}
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 || '';
}
}
await calculateTargetRating(null);
} catch (err) {
console.error('Error refreshing round history:', err);
while (result.firstChild) result.removeChild(result.firstChild);
_targetResultMsg(result, 'error', 'Network error during refresh. Please try again.');
}
}
// ── Mobile player card toggle ──────────────────────
let openMobilePdgaNumber = null;
function toggleMobilePlayerCard(pdgaNumber) {
const card = document.getElementById('m-card-' + pdgaNumber);
if (!card) return;
const isOpen = card.classList.contains('is-open');
// Close previously open card
if (openMobilePdgaNumber !== null && openMobilePdgaNumber !== pdgaNumber) {
const prevCard = document.getElementById('m-card-' + openMobilePdgaNumber);
if (prevCard) {
prevCard.classList.remove('is-open');
prevCard.setAttribute('aria-expanded', 'false');
}
openMobilePdgaNumber = null;
}
if (isOpen) {
card.classList.remove('is-open');
card.setAttribute('aria-expanded', 'false');
openMobilePdgaNumber = null;
return;
}
card.classList.add('is-open');
card.setAttribute('aria-expanded', 'true');
openMobilePdgaNumber = pdgaNumber;
// Init charts inside the expand panel
const expand = card.querySelector('.m-card__expand');
if (expand) {
initChartsIn(expand);
}
}
// ── Mobile add player ──────────────────────────────
async function searchAndAddPlayerMobile(event) {
if (event) event.preventDefault();
const input = document.getElementById('pdga-number-input-mobile');
const pdgaNumber = input ? input.value.trim() : '';
if (!pdgaNumber) {
alert('Please enter a PDGA number');
return;
}
const button = event && event.target ? event.target.querySelector('button[type="submit"]') : null;
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 = 'Add'; }
if (input) input.value = '';
}
}
+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;
}
+16 -3369
View File
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
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');
const hasExcludedRoundsCount = columns.some(col => col.name === 'excluded_rounds_count');
const hasCutoffRating = columns.some(col => col.name === 'cutoff_rating');
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');
});
}
if (!hasExcludedRoundsCount) {
logger.info('Adding excluded_rounds_count column to players table...');
db.run(`ALTER TABLE players ADD COLUMN excluded_rounds_count INTEGER DEFAULT NULL`, (err) => {
if (err) logger.error('Error adding excluded_rounds_count column:', err.message);
else logger.info('Successfully added excluded_rounds_count column');
});
}
if (!hasCutoffRating) {
logger.info('Adding cutoff_rating column to players table...');
db.run(`ALTER TABLE players ADD COLUMN cutoff_rating INTEGER DEFAULT NULL`, (err) => {
if (err) logger.error('Error adding cutoff_rating column:', err.message);
else logger.info('Successfully added cutoff_rating 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;
+108
View File
@@ -0,0 +1,108 @@
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) return reject(err);
// node-sqlite3 leaves lastID = 0 when ON CONFLICT triggers an UPDATE.
// Fall back to a SELECT to get the real id in that case.
if (this.lastID !== 0) return resolve(this.lastID);
db.get('SELECT id FROM courses WHERE link = ?', [courseData.link], (err2, row) => {
if (err2) reject(err2);
else resolve(row ? row.id : 0);
});
}
);
});
}
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);
}
);
});
}
function getOrCreateLayout(courseId, name, par) {
return new Promise((resolve, reject) => {
db.get(
'SELECT id FROM layouts WHERE course_id = ? AND name = ? AND par = ?',
[courseId, name, par],
(err, row) => {
if (err) return reject(err);
if (row) return resolve(row.id);
db.run(
'INSERT INTO layouts (course_id, name, par) VALUES (?, ?, ?)',
[courseId, name, par],
function(err) {
if (err) reject(err);
else resolve(this.lastID);
}
);
}
);
});
}
module.exports = {
saveCourseToDB,
getAllCoursesFromDB,
saveLayoutToDB,
getLayoutsForCourse,
getOrCreateLayout,
updateLayoutRating
};
+309
View File
@@ -0,0 +1,309 @@
const { db } = require('../db');
const { parseDate } = require('../services/rating-calculator');
function getPlayerFromDB(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get(
'SELECT * FROM players WHERE pdga_number = ?',
[pdgaNumber],
(err, row) => {
if (err) reject(err);
else resolve(row);
}
);
});
}
function savePlayerToDB(playerData) {
return new Promise((resolve, reject) => {
db.run(
`INSERT OR REPLACE INTO players (pdga_number, name, current_rating, rating_change, last_updated)
VALUES (?, ?, ?, ?, datetime('now'))`,
[playerData.pdgaNumber, playerData.name, playerData.rating, playerData.ratingChange],
function(err) {
if (err) reject(err);
else resolve(this.lastID);
}
);
});
}
function getRatingHistoryFromDB(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return resolve(null);
db.all(
'SELECT * FROM rating_history WHERE player_id = ? ORDER BY date ASC',
[player.id],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
});
}
function saveRatingHistoryToDB(pdgaNumber, ratingHistory) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return reject(new Error('Player not found'));
db.run('DELETE FROM rating_history WHERE player_id = ?', [player.id], (err) => {
if (err) return reject(err);
if (ratingHistory.length === 0) {
return resolve();
}
let completed = 0;
const total = ratingHistory.length;
ratingHistory.forEach(entry => {
const parsedDate = parseDate(entry.date);
db.run(
'INSERT INTO rating_history (player_id, date, rating) VALUES (?, ?, ?)',
[player.id, parsedDate.toISOString().split('T')[0], entry.rating],
(err) => {
if (err) return reject(err);
completed++;
if (completed === total) {
resolve();
}
}
);
});
});
});
});
}
function getRoundHistoryFromDB(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return resolve([]);
db.all(
'SELECT * FROM round_history WHERE player_id = ? ORDER BY date DESC',
[player.id],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
});
}
function getLastRoundUpdateDate(pdgaNumber) {
return new Promise((resolve, reject) => {
db.get(
'SELECT last_round_update FROM players WHERE pdga_number = ?',
[pdgaNumber],
(err, row) => {
if (err) reject(err);
else resolve(row ? row.last_round_update : null);
}
);
});
}
function updateLastRoundUpdateDate(pdgaNumber) {
return new Promise((resolve, reject) => {
db.run(
'UPDATE players SET last_round_update = CURRENT_TIMESTAMP WHERE pdga_number = ?',
[pdgaNumber],
function(err) {
if (err) reject(err);
else resolve();
}
);
});
}
function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return reject(new Error('Player not found'));
const processRounds = () => {
if (roundData.length === 0) {
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (err) => {
if (err) reject(err);
else resolve();
});
return;
}
const stmt = db.prepare('INSERT OR REPLACE INTO round_history (player_id, date, competition_name, rating) VALUES (?, ?, ?, ?)');
for (const round of roundData) {
stmt.run([player.id, round.date.toISOString().split('T')[0], round.competition || 'Unknown', round.rating]);
}
stmt.finalize((err) => {
if (err) {
reject(err);
} else {
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (updateErr) => {
if (updateErr) reject(updateErr);
else resolve();
});
}
});
};
if (!isIncremental) {
db.run('DELETE FROM round_history WHERE player_id = ?', [player.id], (err) => {
if (err) return reject(err);
processRounds();
});
} else {
processRounds();
}
});
});
}
function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null, excludedRoundsCount = null, cutoffRating = null) {
return new Promise((resolve, reject) => {
db.run(
'UPDATE players SET predicted_rating = ?, std_dev = ?, excluded_rounds_count = ?, cutoff_rating = ? WHERE pdga_number = ?',
[predictedRating, stdDev, excludedRoundsCount, cutoffRating, pdgaNumber],
function(err) {
if (err) reject(err);
else resolve();
}
);
});
}
/**
* Returns monthly rating snapshots for one player (latest entry per calendar month),
* ordered oldest → newest. At most `months` entries; [] if none.
*/
function getMonthlyHistory(pdgaNumber, months = 12) {
return new Promise((resolve, reject) => {
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
if (err) return reject(err);
if (!player) return resolve([]);
db.all(
`SELECT rating
FROM rating_history
WHERE player_id = ?
AND date IN (
SELECT MAX(date)
FROM rating_history
WHERE player_id = ?
GROUP BY strftime('%Y-%m', date)
)
ORDER BY date DESC
LIMIT ?`,
[player.id, player.id, months],
(err, rows) => {
if (err) return reject(err);
resolve(rows.map(r => r.rating).reverse());
}
);
});
});
}
/**
* Fetches the last `months` monthly rating snapshots for ALL players in one query.
* Returns a Map<pdgaNumber, number[]> (oldest → newest per player).
* Use this in bulk-fetch paths to avoid N+1 queries.
*/
function getAllMonthlyHistoriesFromDB(months = 12) {
return new Promise((resolve, reject) => {
db.all(
`SELECT p.pdga_number, rh.date, rh.rating
FROM rating_history rh
JOIN players p ON rh.player_id = p.id
INNER JOIN (
SELECT player_id, MAX(date) AS max_date
FROM rating_history
GROUP BY player_id, strftime('%Y-%m', date)
) latest ON rh.player_id = latest.player_id AND rh.date = latest.max_date
ORDER BY p.pdga_number, rh.date ASC`,
[],
(err, rows) => {
if (err) return reject(err);
const map = new Map();
for (const row of rows) {
if (!map.has(row.pdga_number)) map.set(row.pdga_number, []);
map.get(row.pdga_number).push(row.rating);
}
// Trim each player's history to the requested window
for (const [key, arr] of map) {
if (arr.length > months) map.set(key, arr.slice(-months));
}
resolve(map);
}
);
});
}
/**
* Fetches the full rating history for ALL players in one query.
* Returns Map<pdgaNumber, {rating, date}[]> ordered chronologically (oldest → newest).
* Mirrors getAllMonthlyHistoriesFromDB but returns every point, not monthly snapshots.
*/
function getAllRatingHistoriesFromDB() {
return new Promise((resolve, reject) => {
db.all(
`SELECT p.pdga_number, rh.date, rh.rating
FROM rating_history rh
JOIN players p ON rh.player_id = p.id
ORDER BY p.pdga_number, rh.date ASC`,
[],
(err, rows) => {
if (err) return reject(err);
const map = new Map();
for (const row of rows) {
if (!map.has(row.pdga_number)) map.set(row.pdga_number, []);
map.get(row.pdga_number).push({ date: row.date, rating: row.rating });
}
resolve(map);
}
);
});
}
function getLastRefresh() {
return new Promise((resolve, reject) => {
db.get(
'SELECT MAX(last_updated) AS lastRefresh FROM players',
[],
(err, row) => {
if (err) reject(err);
else resolve(row ? row.lastRefresh : null);
}
);
});
}
module.exports = {
getPlayerFromDB,
savePlayerToDB,
getRatingHistoryFromDB,
saveRatingHistoryToDB,
getRoundHistoryFromDB,
getLastRoundUpdateDate,
updateLastRoundUpdateDate,
saveRoundHistoryToDB,
savePredictedRatingToDB,
getLastRefresh,
getMonthlyHistory,
getAllMonthlyHistoriesFromDB,
getAllRatingHistoriesFromDB
};
+433
View File
@@ -0,0 +1,433 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating, saveCourseToDB, getOrCreateLayout } = require('../models/course');
const { searchTjingCourses, getTjingCourse } = require('../scrapers/tjing');
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 oneYearAgo = new Date();
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
const oneYearAgoStr = oneYearAgo.toISOString().slice(0, 10);
const allCourses = await new Promise((resolve, reject) => {
db.all(
`SELECT c.*,
COUNT(l.id) AS layoutCount,
SUM(CASE WHEN l.last_played >= ? THEN 1 ELSE 0 END) AS activeLayoutCount
FROM courses c
LEFT JOIN layouts l ON l.course_id = c.id
GROUP BY c.id
ORDER BY c.name ASC`,
[oneYearAgoStr],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
res.render('../partials/course-table', { courses: allCourses });
} catch (error) {
logger.error({ err: error }, 'Error loading course table');
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({ err: error }, 'Error loading course layouts');
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({ err: error }, 'Error fetching courses');
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({ err: error }, 'Error fetching layouts');
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({ err, layoutName: layoutDataResult.name }, 'Error updating layout');
}
}
}
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({ err, layoutName: ld.name }, 'Error updating layout');
}
}
}
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' });
}
});
// Search Tjing for courses
router.get('/api/tjing/search', async (req, res) => {
const { q } = req.query;
if (!q || q.trim().length === 0) {
return res.json({ results: [] });
}
const result = await searchTjingCourses(q.trim());
if (result.error) {
logger.warn({ q, err: result.error }, 'Tjing search error');
return res.status(502).json({ error: result.error });
}
res.json({ results: result.data });
});
// Import a course from Tjing
router.post('/api/tjing/import/:tjingId', async (req, res) => {
const { tjingId } = req.params;
const result = await getTjingCourse(tjingId);
if (result.error) {
logger.warn({ tjingId, err: result.error }, 'Tjing import error');
return res.status(502).json({ error: result.error });
}
const courseData = result.data;
try {
const courseId = await saveCourseToDB({
name: courseData.name,
link: `https://tjing.se/courses/${tjingId}`,
city: courseData.address || ''
});
let layoutsImported = 0;
for (const layout of courseData.layouts) {
await getOrCreateLayout(courseId, layout.name, layout.par);
layoutsImported++;
}
logger.info({ courseId, name: courseData.name, layoutsImported }, 'Imported course from Tjing');
res.json({ courseId, layoutsImported });
} catch (err) {
logger.error({ err, tjingId }, 'Failed to save Tjing course to DB');
res.status(500).json({ error: 'Failed to save course to database' });
}
});
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;
+514
View File
@@ -0,0 +1,514 @@
const express = require('express');
const router = express.Router();
const { db } = require('../db');
const { getPlayerFromDB, savePlayerToDB, getRatingHistoryFromDB, saveRatingHistoryToDB, getRoundHistoryFromDB, getLastRoundUpdateDate, updateLastRoundUpdateDate, saveRoundHistoryToDB, savePredictedRatingToDB } = require('../models/player');
const { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHistory } = require('../scrapers/player-http');
const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer');
const { launchBrowser } = require('../scrapers/browser');
const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB, formatDisplayDate } = require('../services/player-service');
const { getTopbarLocals } = require('../services/topbar-service');
const { calculatePredictedRating } = require('../services/rating-calculator');
const { calculateRequiredAverage } = require('../services/target-rating-calculator');
const logger = require('../logger');
let refreshInProgress = false;
router.post('/api/refresh-all', async (req, res, next) => {
if (refreshInProgress) {
logger.info('refresh-all already in progress, rejecting');
return res.status(409).json({ error: 'Refresh already in progress' });
}
refreshInProgress = true;
try {
try {
await refreshAllPlayersInDB();
} catch (err) {
logger.error({ err }, 'refresh-all failed');
}
const page = req.body?.page === 'courses' ? 'courses' : 'players';
const locals = await getTopbarLocals();
res.render('../partials/topbar', { activePage: page, ...locals });
} catch (err) {
next(err);
} finally {
refreshInProgress = false;
}
});
router.get('/partials/ratings-table', async (req, res) => {
try {
const ratings = await getAllRatingsFromDB();
res.render('../partials/ratings-table', { ratings });
} catch (error) {
res.status(500).send('<p>Error loading ratings. Please try again.</p>');
}
});
// Used only by the per-player "refresh rating history" button. The initial table render
// pre-attaches history via getAllRatingsFromDB to avoid the load-then-fetch race.
router.get('/partials/player-history/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
let history = await getRatingHistoryFromDB(pdgaNumber);
if (!history || history.length === 0) {
const html = await fetchRatingHistory(pdgaNumber);
history = parseRatingHistory(html);
try {
await saveRatingHistoryToDB(pdgaNumber, history);
} catch (dbErr) {
logger.error('Failed to save rating history:', dbErr.message);
}
}
const formattedHistory = (history || []).map(row => ({
date: row.date,
rating: row.rating,
displayDate: formatDisplayDate(row.date)
}));
const player = await getPlayerDataFromDB(pdgaNumber);
res.render('../partials/player-history', { pdgaNumber, history: formattedHistory, player });
} catch (error) {
logger.error('Error loading player history:', error.message);
res.status(500).send('<div class="loading-chart">Error loading rating history</div>');
}
});
router.get('/api/ratings', async (req, res) => {
try {
const ratings = await getAllRatingsFromDB();
res.json(ratings);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch ratings' });
}
});
router.get('/api/ratings/progress', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
});
const progressCallback = (progress) => {
res.write(`data: ${JSON.stringify(progress)}\n\n`);
};
getAllRatingsFromDB(progressCallback).then(ratings => {
res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`);
res.end();
}).catch(error => {
res.write(`data: ${JSON.stringify({ status: 'error', error: error.message })}\n\n`);
res.end();
});
req.on('close', () => {
res.end();
});
});
router.get('/api/rating-history/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
const cachedHistory = await getRatingHistoryFromDB(pdgaNumber);
if (cachedHistory && cachedHistory.length > 0) {
logger.info(`Using cached rating history from DB for PDGA ${pdgaNumber}`);
const formattedHistory = cachedHistory.map(row => ({
date: row.date,
rating: row.rating,
displayDate: formatDisplayDate(row.date)
}));
res.json({
pdgaNumber: parseInt(pdgaNumber),
history: formattedHistory
});
return;
}
logger.info(`Fetching rating history for PDGA ${pdgaNumber}...`);
const html = await fetchRatingHistory(pdgaNumber);
const history = parseRatingHistory(html);
try {
await saveRatingHistoryToDB(pdgaNumber, history);
logger.info(`Saved rating history for PDGA ${pdgaNumber} to database`);
} catch (dbErr) {
logger.error(`Failed to save rating history to database:`, dbErr.message);
}
res.json({
pdgaNumber: parseInt(pdgaNumber),
history
});
} catch (error) {
logger.error('Error fetching rating history:', error.message);
res.status(500).json({ error: 'Failed to fetch rating history' });
}
});
router.post('/api/clear-cache', (req, res) => {
try {
db.run('UPDATE players SET last_updated = datetime("now", "-25 hours"), last_round_update = NULL', (err) => {
if (err) {
logger.error('Error clearing database cache:', err);
res.status(500).json({ error: 'Failed to clear database cache' });
return;
}
logger.info('Database cache cleared - all players will be refreshed on next request');
res.json({
success: true,
message: 'Cache cleared - database reset'
});
});
} catch (error) {
logger.error('Error clearing cache:', error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
router.get('/api/search-player/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
logger.info(`Searching for player with PDGA number ${pdgaNumber}`);
const existingPlayer = await getPlayerFromDB(pdgaNumber);
if (existingPlayer) {
return res.json({
alreadyExists: true,
player: {
pdgaNumber: existingPlayer.pdga_number,
name: existingPlayer.name,
rating: existingPlayer.current_rating,
ratingChange: existingPlayer.rating_change
}
});
}
const html = await fetchPlayerDataHTTP(pdgaNumber);
const playerData = parsePlayerData(html, pdgaNumber);
if (playerData.name === 'Unknown' || !playerData.name) {
return res.status(404).json({ error: 'Player not found' });
}
res.json({
alreadyExists: false,
player: playerData
});
} catch (error) {
logger.error('Error searching for player:', error.message);
res.status(500).json({ error: 'Failed to search for player' });
}
});
router.post('/api/add-player', async (req, res) => {
try {
const { pdgaNumber } = req.body;
if (!pdgaNumber) {
return res.status(400).json({ error: 'PDGA number is required' });
}
logger.info(`Adding player with PDGA number ${pdgaNumber}`);
const existingPlayer = await getPlayerFromDB(pdgaNumber);
if (existingPlayer) {
return res.status(409).json({
error: 'Player already exists',
player: {
pdgaNumber: existingPlayer.pdga_number,
name: existingPlayer.name,
rating: existingPlayer.current_rating
}
});
}
const html = await fetchPlayerDataHTTP(pdgaNumber);
const playerData = parsePlayerData(html, pdgaNumber);
if (playerData.name === 'Unknown' || !playerData.name) {
return res.status(404).json({ error: 'Player not found' });
}
await savePlayerToDB(playerData);
logger.info(`Successfully added player: ${playerData.name} (#${pdgaNumber})`);
res.json({
success: true,
player: playerData
});
} catch (error) {
logger.error('Error adding player:', error.message);
res.status(500).json({ error: 'Failed to add player' });
}
});
router.post('/api/refresh-player/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
logger.info(`Manually refreshing player data for PDGA ${pdgaNumber}`);
const html = await fetchPlayerDataHTTP(pdgaNumber);
const playerData = parsePlayerData(html, pdgaNumber);
await savePlayerToDB(playerData);
res.json({
success: true,
player: playerData
});
} catch (error) {
logger.error('Error refreshing player data:', error.message);
res.status(500).json({ error: 'Failed to refresh player data' });
}
});
router.post('/api/refresh-rating-history/:pdgaNumber', async (req, res) => {
try {
const { pdgaNumber } = req.params;
logger.info(`=== Manually refreshing rating history for PDGA ${pdgaNumber} ===`);
const startTime = Date.now();
const html = await fetchRatingHistory(pdgaNumber);
const fetchTime = Date.now() - startTime;
logger.info(`HTML fetch completed in ${fetchTime}ms, received ${html.length} bytes`);
const parseStartTime = Date.now();
const history = parseRatingHistory(html);
const parseTime = Date.now() - parseStartTime;
logger.info(`Parsing completed in ${parseTime}ms, found ${history.length} history entries`);
if (history.length > 0) {
logger.debug({ entries: history.slice(0, 3) }, 'Sample history entries');
} else {
logger.debug({ htmlSample: html.substring(0, 500) }, 'No history entries found');
}
const dbStartTime = Date.now();
await saveRatingHistoryToDB(pdgaNumber, history);
const dbTime = Date.now() - dbStartTime;
logger.info(`Database save completed in ${dbTime}ms`);
const formattedHistory = history.map(entry => ({
date: entry.date,
rating: entry.rating,
displayDate: entry.displayDate
}));
logger.info(`=== Rating history refresh completed for PDGA ${pdgaNumber} ===`);
res.json({
success: true,
history: formattedHistory
});
} catch (error) {
logger.error(`=== Error refreshing rating history for PDGA ${req.params.pdgaNumber} ===`);
logger.error('Error type:', error.constructor.name);
logger.error('Error message:', error.message);
res.status(500).json({
error: 'Failed to refresh rating history',
details: error.message,
code: error.code
});
}
});
router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
req.setTimeout(600000);
res.setTimeout(600000);
let browser = null;
const { pdgaNumber } = req.params;
try {
const lastRoundUpdate = await getLastRoundUpdateDate(pdgaNumber);
const sinceDate = lastRoundUpdate ? new Date(lastRoundUpdate) : null;
if (sinceDate) {
const hoursSinceUpdate = (Date.now() - sinceDate.getTime()) / (1000 * 60 * 60);
if (hoursSinceUpdate < 24) {
const hoursRemaining = Math.ceil(24 - hoursSinceUpdate);
return res.status(429).json({
error: 'Rate limit exceeded',
message: `Prediction can only be refreshed once every 24 hours. Please try again in ${hoursRemaining} hour(s).`,
lastUpdate: sinceDate.toISOString(),
hoursRemaining: hoursRemaining
});
}
}
const isIncremental = !!sinceDate;
logger.info(`${isIncremental ? 'Incrementally updating' : 'Fully refreshing'} round history for PDGA ${pdgaNumber}${sinceDate ? ` since ${sinceDate.toDateString()}` : ''}`);
browser = await launchBrowser();
let officialHistory;
try {
officialHistory = await getOfficialRatingHistory(browser, pdgaNumber);
if (officialHistory.length > 0) {
await saveRatingHistoryToDB(pdgaNumber, officialHistory);
}
} catch (historyError) {
logger.error('Failed to fetch official history:', historyError.message);
officialHistory = [];
}
let allRounds = [];
try {
logger.info(`Using optimized approach: /details + new tournaments only for PDGA ${pdgaNumber}...`);
allRounds = await getOptimizedPlayerRounds(browser, pdgaNumber);
if (allRounds.length > 0) {
const roundsForDB = allRounds.map(round => ({
rating: round.rating,
date: round.date,
competition: round.competition
}));
await saveRoundHistoryToDB(pdgaNumber, roundsForDB, false);
logger.info(`✓ Saved ${allRounds.length} rounds using optimized approach`);
await updateLastRoundUpdateDate(pdgaNumber);
} else {
logger.info(' No rounds found');
}
} catch (detailsError) {
logger.error('Failed to fetch rounds using optimized approach:', detailsError.message);
allRounds = [];
}
await browser.close();
browser = null;
const dbRounds = await getRoundHistoryFromDB(pdgaNumber);
const roundsForPrediction = dbRounds.map(round => ({
rating: round.rating,
date: new Date(round.date),
competition: round.competition_name
}));
const result = calculatePredictedRating(roundsForPrediction);
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev, result.excludedRoundsCount, result.cutoffRating);
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,
excludedRoundsCount: result.excludedRoundsCount,
cutoffRating: result.cutoffRating,
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'
});
}
});
router.post('/api/calculate-target-rating/:pdgaNumber', async (req, res) => {
const { pdgaNumber } = req.params;
const pdgaNum = parseInt(pdgaNumber, 10);
const { targetRating, rounds } = req.body || {};
if (!Number.isFinite(pdgaNum) || pdgaNum <= 0) {
return res.status(400).json({ error: 'Invalid PDGA number' });
}
const target = Number(targetRating);
const numRounds = Number(rounds);
if (!Number.isFinite(target) || target < 400 || target > 1200) {
return res.status(400).json({
error: 'Invalid target rating',
details: 'targetRating must be a number between 400 and 1200'
});
}
if (!Number.isInteger(numRounds) || numRounds < 1 || numRounds > 20) {
return res.status(400).json({
error: 'Invalid round count',
details: 'rounds must be an integer between 1 and 20'
});
}
try {
const dbRounds = await getRoundHistoryFromDB(pdgaNum);
if (!dbRounds || dbRounds.length === 0) {
return res.status(404).json({
error: 'No round history available',
details: 'Refresh the player round history before calculating a target.',
errorType: 'NO_ROUNDS'
});
}
const roundRatings = dbRounds.map(r => ({
rating: r.rating,
date: new Date(r.date),
competition: r.competition_name
}));
const result = calculateRequiredAverage(roundRatings, target, numRounds);
logger.info(`Target rating calc for PDGA ${pdgaNum}: target=${target} rounds=${numRounds} -> avg=${result.requiredAverage}`);
return res.json({
success: true,
pdgaNumber: pdgaNum,
targetRating: target,
rounds: numRounds,
currentRating: result.currentPredicted,
requiredAverage: result.requiredAverage,
predictedRating: result.simulatedPredicted,
warning: result.warning,
iterations: result.iterations,
sensitivity: result.sensitivity
});
} catch (err) {
logger.error(`Target rating calc failed for PDGA ${pdgaNum}: ${err.message}`);
return res.status(500).json({
error: 'Failed to calculate target rating',
details: err.message,
errorType: err.code || 'CALC_ERROR',
timestamp: new Date().toISOString(),
suggestion: 'Try refreshing the round history and retrying.'
});
}
});
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 };
+391
View File
@@ -0,0 +1,391 @@
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 { urls: newTournamentUrls, counts } = await page.evaluate((afterTimestamp) => {
const afterDate = new Date(afterTimestamp);
const tables = document.querySelectorAll('table[id*="player-results"]');
const urls = [];
const seenUrls = new Set();
let table = 0;
let recentEvents = 0;
let recentEventsAnchorsSeen = 0;
let recentEventsSkippedDuplicates = 0;
tables.forEach(tbl => {
const rows = tbl.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) {
const absoluteUrl = new URL(href, location.origin).href;
if (!seenUrls.has(absoluteUrl)) {
seenUrls.add(absoluteUrl);
urls.push({
url: absoluteUrl,
date: dateStr,
name: tournamentCell.innerText.trim(),
source: 'table'
});
table++;
}
}
}
}
}
});
});
const recentAnchors = document.querySelectorAll('.recent-events a[href*="/tour/event/"]');
recentAnchors.forEach(anchor => {
recentEventsAnchorsSeen++;
const href = anchor.getAttribute('href');
if (href) {
const absoluteUrl = new URL(href, location.origin).href;
if (seenUrls.has(absoluteUrl)) {
recentEventsSkippedDuplicates++;
} else {
seenUrls.add(absoluteUrl);
urls.push({
url: absoluteUrl,
date: null,
name: anchor.innerText.trim() || 'Recent event',
source: 'recent-events'
});
recentEvents++;
}
}
});
return { urls, counts: { table, recentEvents, recentEventsAnchorsSeen, recentEventsSkippedDuplicates } };
}, afterDate.getTime());
logger.info({
pdgaNumber,
afterDate: afterDate.toISOString(),
tableMatches: counts.table,
recentEventsMatches: counts.recentEvents,
recentEventsAnchorsSeen: counts.recentEventsAnchorsSeen,
recentEventsSkippedDuplicates: counts.recentEventsSkippedDuplicates,
totalUrlsToScrape: newTournamentUrls.length
}, 'new tournament URL discovery completed');
for (const tournamentData of newTournamentUrls) {
try {
if (tournamentData.source === 'recent-events') {
logger.debug({ pdgaNumber, url: tournamentData.url }, 'recent-events: scraping tournament');
} else {
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));
let parsedDate;
if (tournamentData.date !== null) {
parsedDate = parseDate(tournamentData.date);
} else {
const eventDateStr = await page.evaluate(() => {
const body = document.body ? document.body.innerText : '';
const m = body.match(/\d{1,2}\s+to\s+\d{1,2}-[A-Za-z]{3}-\d{4}/)
|| body.match(/\d{1,2}-[A-Za-z]{3}-\d{4}/);
return m ? m[0] : null;
});
if (eventDateStr) {
parsedDate = parseDate(eventDateStr);
if (!(parsedDate > afterDate)) {
logger.warn({
pdgaNumber,
url: tournamentData.url,
eventDateStr,
parsedDate: parsedDate ? parsedDate.toISOString() : null,
afterDate: afterDate.toISOString()
}, 'recent-events: extracted event date is not newer than afterDate, likely captured a non-tournament date — skipping');
continue;
}
logger.debug({ pdgaNumber, url: tournamentData.url, eventDateStr }, 'recent-events: extracted date from event page');
} else {
logger.warn({ pdgaNumber, url: tournamentData.url }, 'recent-events: could not extract date from event page, skipping tournament');
continue;
}
}
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) {
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
};
+111
View File
@@ -0,0 +1,111 @@
const logger = require('../logger');
const TJING_API = 'https://api.tjing.se/graphql';
const FETCH_TIMEOUT_MS = 8000;
async function tjingFetch(query) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let response;
try {
response = await fetch(TJING_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
signal: controller.signal
});
} catch (err) {
if (err.name === 'AbortError') {
return { error: 'Tjing API request timed out' };
}
return { error: `Network error: ${err.message}` };
} finally {
clearTimeout(timer);
}
if (!response.ok) {
return { error: `Tjing API returned ${response.status}` };
}
let data;
try {
data = await response.json();
} catch (err) {
return { error: 'Invalid JSON from Tjing API' };
}
if (data.errors && data.errors.length > 0) {
return { error: data.errors[0].message };
}
return { data: data.data };
}
async function searchTjingCourses(searchTerm) {
const query = `{
courses(first: 10, filter: { search: "${searchTerm.replace(/"/g, '\\"')}" }) {
id
name
address
type
}
}`;
const result = await tjingFetch(query);
if (result.error) return result;
return { data: result.data.courses || [] };
}
async function getTjingCourse(courseId) {
const query = `{
course(courseId: "${courseId.replace(/"/g, '\\"')}") {
id
name
address
layouts {
id
name
published
latestVersion {
holes {
number
par
}
}
}
}
}`;
const result = await tjingFetch(query);
if (result.error) return result;
const course = result.data.course;
if (!course) {
return { error: 'Course not found' };
}
// Calculate total par per layout from holes
const layouts = (course.layouts || [])
.filter(l => l.published && l.latestVersion && l.latestVersion.holes.length > 0)
.map(l => ({
name: l.name,
par: l.latestVersion.holes.reduce((sum, h) => sum + h.par, 0),
holes: l.latestVersion.holes.length
}));
return {
data: {
name: course.name,
address: course.address,
tjingId: course.id,
layouts
}
};
}
module.exports = {
searchTjingCourses,
getTjingCourse
};
+371
View File
@@ -0,0 +1,371 @@
const { db } = require('../db');
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB, getAllRatingHistoriesFromDB } = require('../models/player');
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
const { calculatePredictedRating } = require('./rating-calculator');
const logger = require('../logger');
function formatDisplayDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
day: '2-digit', month: 'short', year: 'numeric'
});
}
// Derives previous-month rating and the delta to it. Prefers PDGA's reported
// rating_change (canonical), falls back to our own monthly snapshots when
// rating_change is missing — common for players whose latest scrape failed.
function deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory) {
if (rating != null && rawRatingChange != null) {
return { lastMonthRating: rating - rawRatingChange, ratingChange: rawRatingChange };
}
if (rating != null && monthlyHistory && monthlyHistory.length >= 1) {
// The "last month" snapshot depends on whether current_rating is already in
// history. If equal, current is the most recent entry — last month is the one
// before it. If not, current is newer than history — the latest entry IS last month.
const lastIdx = monthlyHistory.length - 1;
const lastMonth = (monthlyHistory[lastIdx] === rating)
? (monthlyHistory.length >= 2 ? monthlyHistory[lastIdx - 1] : null)
: monthlyHistory[lastIdx];
if (lastMonth != null) {
return { lastMonthRating: lastMonth, ratingChange: rating - lastMonth };
}
}
return { lastMonthRating: null, ratingChange: rawRatingChange };
}
async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } = {}) {
try {
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
if (cachedPlayer) {
logger.debug(`Loading PDGA ${pdgaNumber} from DB (source of truth)`);
let predictedRating = cachedPlayer.predicted_rating;
let stdDev = cachedPlayer.std_dev;
let excludedRoundsCount = cachedPlayer.excluded_rounds_count;
let cutoffRating = cachedPlayer.cutoff_rating;
if (!predictedRating || predictedRating === 0) {
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
stdDev = updatedPlayer?.std_dev;
excludedRoundsCount = updatedPlayer?.excluded_rounds_count;
cutoffRating = updatedPlayer?.cutoff_rating;
}
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,
excludedRoundsCount: (excludedRoundsCount != null && excludedRoundsCount >= 0) ? excludedRoundsCount : null,
cutoffRating: (cutoffRating != null && cutoffRating > 0) ? cutoffRating : null,
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, result.excludedRoundsCount, result.cutoffRating);
return result.rating;
}
return 0;
} catch (err) {
logger.error(`Error getting predicted rating from DB for ${pdgaNumber}:`, err.message);
return 0;
}
}
async function getAllRatingsFromDB(progressCallback = null) {
try {
const allPlayers = await new Promise((resolve, reject) => {
db.all(
'SELECT pdga_number, name, current_rating, rating_change FROM players ORDER BY pdga_number',
[],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
logger.info(`Loading ${allPlayers.length} players from database...`);
// Fetch all monthly histories in one query so the per-player loop doesn't add N extra queries
const monthlyHistoryMap = await getAllMonthlyHistoriesFromDB(12);
const ratingHistoryMap = await getAllRatingHistoriesFromDB();
const ratings = [];
const total = allPlayers.length;
for (let i = 0; i < allPlayers.length; i++) {
const player = allPlayers[i];
const pdgaNumber = player.pdga_number;
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'loading'
});
}
try {
const playerData = await getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory: false });
if (playerData) {
playerData.monthlyHistory = monthlyHistoryMap.get(pdgaNumber) ?? [];
const rawHistory = ratingHistoryMap.get(pdgaNumber) ?? [];
playerData.ratingHistory = rawHistory.map(row => ({
date: row.date,
rating: row.rating,
displayDate: formatDisplayDate(row.date)
}));
// Re-derive now that history is attached — bulk path skipped includeMonthlyHistory
const derived = deriveMonthlyDeltas(playerData.rating, player.rating_change, playerData.monthlyHistory);
playerData.lastMonthRating = derived.lastMonthRating;
playerData.ratingChange = derived.ratingChange;
ratings.push(playerData);
}
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'completed',
name: playerData ? playerData.name : player.name
});
}
} catch (error) {
logger.error(`Failed to load PDGA ${pdgaNumber} from database:`, error.message);
const errorRating = player.current_rating;
const errorRatingChange = player.rating_change;
const errorData = {
pdgaNumber: parseInt(pdgaNumber),
name: player.name || 'Database Error',
rating: errorRating,
ratingChange: errorRatingChange,
predictedRating: null,
stdDev: null,
excludedRoundsCount: null,
cutoffRating: null,
lastMonthRating: (errorRating != null && errorRatingChange != null) ? errorRating - errorRatingChange : null,
deltaPredicted: null,
monthlyHistory: [],
ratingHistory: []
};
ratings.push(errorData);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'error',
name: player.name || 'Database Error'
});
}
}
}
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
} catch (error) {
logger.error('Error loading players from database:', error);
return [];
}
}
async function refreshAllPlayersInDB(progressCallback = null) {
try {
const allPlayers = await new Promise((resolve, reject) => {
db.all(
'SELECT pdga_number, name FROM players ORDER BY pdga_number',
[],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
logger.info(`Refreshing ${allPlayers.length} players from database...`);
const ratings = [];
const total = allPlayers.length;
for (let i = 0; i < allPlayers.length; i++) {
const player = allPlayers[i];
const pdgaNumber = player.pdga_number;
logger.info(`Refreshing PDGA ${pdgaNumber}... (${i + 1}/${total})`);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'loading'
});
}
try {
const playerData = await scrapePDGARating(pdgaNumber);
ratings.push(playerData);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'completed',
name: playerData.name
});
}
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
logger.error(`Failed to refresh PDGA ${pdgaNumber}:`, error.message);
const errorData = {
pdgaNumber: parseInt(pdgaNumber),
name: player.name || 'Error',
rating: 0,
ratingChange: null,
predictedRating: null
};
ratings.push(errorData);
if (progressCallback) {
progressCallback({
current: i + 1,
total,
pdgaNumber,
status: 'error',
name: player.name || 'Error'
});
}
}
}
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
} catch (error) {
logger.error('Error refreshing all players:', error);
return [];
}
}
/**
* Aggregates KPI summary stats from an already-fetched player array.
* All fields are derived from the player list — no extra DB queries.
*/
function computeKpis(players) {
const active = players.filter(p => p.rating != null && p.rating > 0);
const avg = active.length > 0
? Math.round(active.reduce((sum, p) => sum + p.rating, 0) / active.length)
: null;
return {
tracked: players.length,
active: active.length,
avg,
climbing: players.filter(p => p.ratingChange != null && p.ratingChange > 0).length,
slipping: players.filter(p => p.ratingChange != null && p.ratingChange < 0).length
};
}
module.exports = {
getPlayerDataFromDB,
scrapePDGARating,
getPredictedRatingFromDB,
getAllRatingsFromDB,
refreshAllPlayersInDB,
computeKpis,
formatDisplayDate
};
+241
View File
@@ -0,0 +1,241 @@
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, excludedRoundsCount: null, cutoffRating: null };
}
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, excludedRoundsCount: null, cutoffRating: null };
}
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, excludedRoundsCount: null, cutoffRating: null };
}
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);
let excludedRoundsCount = 0;
let cutoffRating = null;
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);
excludedRoundsCount = stdDevOutliers.length + hundredPointOutliers.length;
cutoffRating = Math.round(Math.max(stdDevCutoff, hundredPointCutoff));
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`);
excludedRoundsCount = 0;
cutoffRating = null;
}
} 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, excludedRoundsCount, cutoffRating };
}
module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };
+81
View File
@@ -0,0 +1,81 @@
const { calculatePredictedRating, getNextPDGAUpdateDate } = require('./rating-calculator');
const logger = require('../logger');
function calculateRequiredAverage(roundRatings, targetRating, numRounds) {
if (!Array.isArray(roundRatings) || roundRatings.length === 0) {
const err = new Error('No round history');
err.code = 'NO_ROUNDS';
throw err;
}
const currentPredicted = calculatePredictedRating(roundRatings).rating;
const nextUpdate = getNextPDGAUpdateDate();
const syntheticDate = new Date(nextUpdate.getTime() - 24 * 60 * 60 * 1000);
const simulate = (R) => {
const synthetic = [];
for (let i = 0; i < numRounds; i++) {
synthetic.push({ rating: R, date: syntheticDate, competition: 'TARGET_SIM' });
}
return calculatePredictedRating([...roundRatings, ...synthetic]).rating;
};
let lo = 400;
let hi = 1200;
let iterations = 0;
const maxIterations = 30;
let exactMatchAvg = null;
while (iterations < maxIterations && (hi - lo) >= 0.5) {
const mid = (lo + hi) / 2;
const predicted = simulate(mid);
if (predicted === targetRating) {
exactMatchAvg = mid;
// Narrow toward smaller R that still hits target — but break to bound iterations.
hi = mid;
} else if (predicted < targetRating) {
lo = mid;
} else {
hi = mid;
}
iterations++;
}
const candidate = exactMatchAvg !== null ? exactMatchAvg : (lo + hi) / 2;
const requiredAverage = Math.round(candidate * 10) / 10;
const simulatedPredicted = simulate(requiredAverage);
let warning = null;
if (requiredAverage >= 1199.5) {
warning = 'Target may be unreachable with this number of rounds within the simulated range [400, 1200].';
} else if (requiredAverage <= 400.5) {
warning = 'Required average is at the lower bound — target may already be exceeded or rounds would drag rating down.';
} else if (requiredAverage > 1050) {
warning = 'Required average is extremely high (>1050) — practically very difficult.';
} else if (requiredAverage < 600) {
warning = 'Required average is very low (<600) — check that your target is reasonable.';
} else if (iterations >= maxIterations && Math.abs(simulatedPredicted - targetRating) > 1) {
warning = 'Could not converge precisely; result is approximate.';
}
const lowerAvg = Math.max(400, Math.round((requiredAverage - 1) * 10) / 10);
const upperAvg = Math.min(1200, Math.round((requiredAverage + 1) * 10) / 10);
const sensitivity = {
lower: { average: lowerAvg, predicted: simulate(lowerAvg) },
target: { average: requiredAverage, predicted: simulatedPredicted },
upper: { average: upperAvg, predicted: simulate(upperAvg) }
};
logger.debug(`Target rating calc: target=${targetRating} rounds=${numRounds} → avg=${requiredAverage} (iterations=${iterations}, simulated=${simulatedPredicted})`);
return {
requiredAverage,
currentPredicted,
simulatedPredicted,
iterations,
warning,
sensitivity
};
}
module.exports = { calculateRequiredAverage };
+40
View File
@@ -0,0 +1,40 @@
const { getLastRefresh } = require('../models/player');
const { getNextPDGAUpdateDate } = require('./rating-calculator');
const logger = require('../logger');
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function formatRelative(isoString) {
if (!isoString) return 'Never';
const then = new Date(isoString.replace(' ', 'T') + (isoString.endsWith('Z') ? '' : 'Z'));
const diffMs = Date.now() - then.getTime();
if (Number.isNaN(diffMs) || diffMs < 0) return 'Just now';
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return 'Just now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min} min ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr} h ago`;
const day = Math.floor(hr / 24);
if (day === 1) return 'Yesterday';
if (day < 7) return `${day} days ago`;
return then.toISOString().slice(0, 10);
}
function formatNextUpdate(date) {
return `${DAY_NAMES[date.getDay()]} ${date.getDate()} ${MONTH_NAMES[date.getMonth()]}`;
}
async function getTopbarLocals() {
const nextUpdate = formatNextUpdate(getNextPDGAUpdateDate());
try {
const lastIso = await getLastRefresh();
return { lastRefresh: formatRelative(lastIso), nextUpdate };
} catch (err) {
logger.warn({ err }, 'topbar locals fallback');
return { lastRefresh: 'Unknown', nextUpdate };
}
}
module.exports = { getTopbarLocals };
+38
View File
@@ -0,0 +1,38 @@
<% var body = `
<main class="page-courses">
<section class="action-card">
<div class="action-card-tabs" role="tablist">
<button class="action-tab is-active" role="tab" aria-selected="true" data-tab="find" id="tab-find">Find courses</button>
<button class="action-tab" role="tab" aria-selected="false" data-tab="tjing" id="tab-tjing">Import from Tjing</button>
</div>
<div class="action-card-body">
<div class="action-pane is-active" id="tab-pane-find" role="tabpanel" aria-labelledby="tab-find">
<input type="text" id="course-filter-input" placeholder="Find a course…" autocomplete="off">
<p class="action-hint">Filters the list below as you type.</p>
</div>
<div class="action-pane" id="tab-pane-tjing" role="tabpanel" aria-labelledby="tab-tjing" hidden>
<div class="tjing-search-row">
<input type="text" id="tjing-search-input" placeholder="Search Tjing courses…" autocomplete="off">
<button id="tjing-search-btn" class="btn-primary" onclick="searchTjing()">Search Tjing</button>
</div>
<p class="action-hint">Find and import Swedish courses from tjing.se.</p>
<div id="tjing-results"></div>
</div>
</div>
</section>
<div class="results-bar">
<span class="results-count">Showing <strong id="visible-count">0</strong> of <strong id="total-count">0</strong> courses</span>
</div>
<div id="course-table-region" hx-get="/partials/course-table" hx-trigger="load, refresh from:body" hx-swap="innerHTML"></div>
</main>
`; %>
<%- include('../partials/layout', {
title: 'PDGA Courses - Sweden',
activePage: 'courses',
cssFiles: ['courses.css'],
jsFiles: ['courses.js'],
body: body
}) %>
+145
View File
@@ -0,0 +1,145 @@
<% 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>
<!-- Mobile sticky add-bar (visible only on mobile via CSS) -->
<form class="mobile-add-bar" onsubmit="searchAndAddPlayerMobile(event)">
<input
id="pdga-number-input-mobile"
name="pdga"
type="text"
inputmode="numeric"
placeholder="PDGA #"
oninput="this.value = this.value.replace(/\\D/g,'')"
aria-label="PDGA number"
/>
<button type="submit" class="btn-primary">Add</button>
</form>
`; %>
<% var modals = `
<!-- 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>
<!-- Target Rating Modal -->
<div id="target-rating-modal" class="modal" onclick="closeTargetRatingModal(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closeTargetRatingModal()">&times;</button>
<div class="modal-header" id="target-rating-modal-header">Calculate Target Rating</div>
<div class="modal-body" id="target-rating-modal-body">
<form id="target-rating-form" onsubmit="calculateTargetRating(event)">
<input type="hidden" id="target-rating-pdga" value="">
<div class="form-row">
<label for="target-rating-input">Target predicted rating</label>
<input type="number" id="target-rating-input" min="400" max="1200" step="1" required>
</div>
<div class="form-row">
<label for="target-rounds-input">Number of rounds</label>
<input type="number" id="target-rounds-input" min="1" max="20" step="1" value="4" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-confirm" id="target-rating-submit">Calculate</button>
</div>
</form>
<div id="target-rating-result" class="target-rating-result" style="display:none;"></div>
</div>
</div>
</div>
`; %>
<%- include('../partials/layout', {
title: 'PDGA Ratings',
activePage: 'players',
cssFiles: ['players.css'],
jsFiles: ['tooltips.js', 'chart.js', 'players.js'],
initScript: 'setupAfterTableSwap();',
body: body,
modals: modals
}) %>
+39
View File
@@ -0,0 +1,39 @@
<%
var _query = (typeof query !== 'undefined') ? query : null;
var _total = (typeof total !== 'undefined') ? total : courses.length;
%>
<% if (courses.length === 0) { %>
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No courses found.</p>
<% } else { %>
<div class="mobile-section-head">
<span class="kicker">Showing <%= courses.length %> of <%= _total %></span>
<a href="/courses">View all &rarr;</a>
</div>
<div class="mobile-list">
<% courses.forEach(function(course) {
var lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
%>
<div class="m-course-card" id="m-course-<%= course.id %>"
tabindex="0" role="button" aria-expanded="false" aria-label="Expand course details"
onclick="toggleMobileCourseLayouts(<%= course.id %>)"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleMobileCourseLayouts(<%= course.id %>);}">
<div class="m-course-card__head">
<div class="m-course-card__name-stack">
<div class="m-course-name-row">
<span class="m-course-name"><%= course.name %></span>
</div>
<div class="m-course-card__meta"><%= course.city %> &middot; <%= lastUpdated %></div>
</div>
<span class="m-chevron">&#9660;</span>
</div>
<div class="m-course-card__expand">
<div id="m-layouts-container-<%= course.id %>" class="layouts-container">
<div class="no-layouts">Tap to load layouts...</div>
</div>
</div>
</div>
<% }); %>
</div>
<% } %>
+67
View File
@@ -0,0 +1,67 @@
<% if (!layouts || layouts.length === 0) { %>
<div class="no-layouts">No layouts found. Click the refresh button to scrape layouts.</div>
<% } else {
var oneYearAgo = new Date();
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
var activeLayouts = [];
var inactiveLayouts = [];
layouts.forEach(function(l) {
if (l.last_played && new Date(l.last_played) >= oneYearAgo) {
activeLayouts.push(l);
} else {
inactiveLayouts.push(l);
}
});
var RATING_TIER_HIGH = 970;
var RATING_TIER_MID = 940;
function ratingTier(r) {
if (r == null) return null;
if (r >= RATING_TIER_HIGH) return 'green';
if (r >= RATING_TIER_MID) return 'amber';
return 'orange';
}
%>
<div class="layouts-header">
<span class="layouts-kicker">LAYOUTS</span>
<span class="layouts-count"><%= activeLayouts.length %> active &middot; <%= inactiveLayouts.length %> inactive</span>
</div>
<ul class="layout-list">
<% activeLayouts.forEach(function(l) { var tier = ratingTier(l.mean_rating); %>
<li class="layout-card layout-card--active">
<div class="layout-info">
<span class="layout-name"><%= l.name %></span>
<span class="layout-last-played">Last played: <%= l.last_played %></span>
</div>
<div class="layout-chips">
<span class="chip chip-par">Par <%= l.par %></span>
<% if (tier) { %><span class="chip chip-rating chip-rating--<%= tier %>">Rating: <%= Math.round(l.mean_rating) %></span><% } %>
</div>
</li>
<% }); %>
</ul>
<% if (inactiveLayouts.length > 0) { %>
<div class="inactive-layouts">
<button class="inactive-toggle" type="button" onclick="toggleInactiveLayouts(this)" aria-expanded="false">
<span>Inactive layouts (<%= inactiveLayouts.length %>) — Not played in last year</span>
<i class="icon-chev fas fa-chevron-down"></i>
</button>
<ul class="layout-list inactive-layouts-body" hidden>
<% inactiveLayouts.forEach(function(l) { %>
<li class="layout-card layout-card--inactive">
<div class="layout-info">
<span class="layout-name"><%= l.name %></span>
<% if (l.last_played) { %>
<span class="layout-last-played">Last played: <%= l.last_played %></span>
<% } else { %>
<span class="layout-never-played">Never played</span>
<% } %>
</div>
<div class="layout-chips">
<span class="chip chip-par">Par <%= l.par %></span>
</div>
</li>
<% }); %>
</ul>
</div>
<% } %>
<% } %>
+49
View File
@@ -0,0 +1,49 @@
<% if (!courses || courses.length === 0) { %>
<p style="text-align: center; color: var(--ink-3); padding: 40px 0;">No courses found. Use "Import from Tjing" or scrape courses from PDGA.</p>
<% } else { %>
<div class="course-grid" data-total-count="<%= courses.length %>">
<div class="course-row course-row--header" role="row">
<div class="course-header-cell">Course</div>
<div class="course-header-cell">City</div>
<div class="course-header-cell">Last updated</div>
<div class="course-header-cell"></div>
</div>
<% courses.forEach(function(course) {
var layoutCount = course.layoutCount || 0;
var activeLayoutCount = course.activeLayoutCount || 0;
%>
<div class="course-row expandable-row" data-course-id="<%= course.id %>" data-course-name="<%= (course.name || '').toLowerCase() %>" data-course-city="<%= (course.city || '').toLowerCase() %>" onclick="toggleCourseLayouts(<%= course.id %>)">
<div class="course-cell">
<span class="course-name"><%= course.name %></span>
<span class="course-meta">
<% if (layoutCount > 0) { %>
<% if (activeLayoutCount !== layoutCount) { %>
<%= layoutCount %> layouts &middot; <%= activeLayoutCount %> active
<% } else { %>
<%= layoutCount %> layouts
<% } %>
<% } else { %>
No layouts
<% } %>
</span>
</div>
<div class="course-city"><%= course.city || '—' %></div>
<div class="course-updated"><%= course.last_updated ? new Date(course.last_updated).toISOString().slice(0,10) : '—' %></div>
<div class="course-actions">
<button class="icon-btn refresh-icon" onclick="event.stopPropagation(); scrapeLayouts(<%= course.id %>, this)" title="Refresh layouts" aria-label="Refresh layouts">
<i class="fas fa-sync-alt"></i>
</button>
<button class="icon-btn icon-chev" onclick="event.stopPropagation(); toggleCourseLayouts(<%= course.id %>)" title="Expand row" aria-label="Expand">
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<div class="expanded-content" id="course-layouts-<%= course.id %>">
<div class="expanded-cell">
<div class="loading">Loading layouts…</div>
</div>
</div>
<% }); %>
</div>
<%- include('course-cards', { courses: courses, total: courses.length }) %>
<% } %>
+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>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<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">
<link rel="stylesheet" href="/css/mobile.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>
+63
View File
@@ -0,0 +1,63 @@
<%
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>
<% if (player.stdDev != null && player.rating) { %>
<div>
<dt>Round spread</dt>
<dd>±<%= player.stdDev %></dd>
</div>
<div>
<dt>Rating range</dt>
<dd><%= player.rating - player.stdDev %><%= player.rating + player.stdDev %></dd>
</div>
<% } %>
<% if (player.excludedRoundsCount != null && player.rating) { %>
<div>
<dt>Excluded rounds</dt>
<dd><%= player.excludedRoundsCount %></dd>
</div>
<% } %>
<% if (player.cutoffRating != null && player.rating) { %>
<div>
<dt>Cutoff rating</dt>
<dd><%= player.cutoffRating %></dd>
</div>
<% } %>
</dl>
</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>
+144
View File
@@ -0,0 +1,144 @@
<%
// Mobile sparkline helper — parametrised, used only in this partial
function renderSparkline(values, opts) {
opts = opts || {};
var w = opts.w || 70;
var h = opts.h || 26;
if (!values || values.length < 2) return '';
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="m-chart-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 { %>
<div class="mobile-section-head">
<span class="kicker">TRACKED PLAYERS &middot; <%= ratings.length %></span>
<button id="trendchart-toggle-mobile" class="pill-button" type="button" aria-pressed="false">Trend chart</button>
</div>
<div class="mobile-list">
<% ratings.forEach(function(player, index) {
var sparkSvg = renderSparkline(player.monthlyHistory || [], { w: 70, h: 26 });
var isFirst = index === 0;
var rank = index + 1;
var ratingIsNull = (player.rating == null);
var ratingCls = ratingIsNull ? 'flat' : (player.ratingChange > 0 ? 'up' : player.ratingChange < 0 ? 'down' : 'flat');
var ratingGlyph = (ratingIsNull || player.ratingChange === 0) ? '' : (player.ratingChange > 0 ? '▲' : '▼');
var ratingNum = ratingIsNull ? '—' : (player.ratingChange > 0 ? '+' + player.ratingChange : String(player.ratingChange));
var predIsNull = (player.predictedRating == null);
var predCls = predIsNull ? 'flat' : (player.deltaPredicted > 0 ? 'up' : player.deltaPredicted < 0 ? 'down' : 'flat');
var predGlyph = (predIsNull || player.deltaPredicted === 0) ? '' : (player.deltaPredicted > 0 ? '▲' : '▼');
var predNum = predIsNull ? '—' : (player.deltaPredicted > 0 ? '+' + player.deltaPredicted : String(player.deltaPredicted));
%>
<div class="m-card" id="m-card-<%= player.pdgaNumber %>"
tabindex="0" role="button" aria-expanded="false" aria-label="Expand player details"
onclick="toggleMobilePlayerCard(<%= player.pdgaNumber %>)"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleMobilePlayerCard(<%= player.pdgaNumber %>);}">
<div class="m-card__head">
<div class="m-rank-chip<%= isFirst ? ' m-rank-chip--first' : '' %>"><%= rank %></div>
<div class="m-card__name-stack">
<span class="m-player-name"><%= player.name %></span>
<span class="m-pdga-num">#<%= player.pdgaNumber %></span>
</div>
<span class="m-chevron">&#9660;</span>
</div>
<div class="m-card__body">
<div class="m-card__stats">
<div class="m-stat-row">
<span class="m-stat-label">RATING</span>
<span class="m-num"><%= player.rating || '—' %></span>
<span class="delta-pill <%= ratingCls %>"><span class="delta-glyph"><%= ratingGlyph %></span><span class="delta-num"><%= ratingNum %></span></span>
</div>
<div class="m-stat-row">
<span class="m-stat-label">PREDICTED</span>
<span class="m-num m-num--predicted"><%= player.predictedRating || '—' %></span>
<span class="delta-pill <%= predCls %> delta-predicted-pill"><span class="delta-glyph"><%= predGlyph %></span><span class="delta-num"><%= predNum %></span></span>
</div>
</div>
<% if (sparkSvg) { %>
<div class="m-card__sparkline"><span class="sparkline"><%- sparkSvg %></span></div>
<% } %>
</div>
<div class="m-card__expand">
<% if (player.ratingHistory && player.ratingHistory.length > 0) { %>
<div class="player-chart m-chart"
data-variant="mobile"
data-history="<%= JSON.stringify(player.ratingHistory) %>">
</div>
<% } %>
<dl class="m-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><span class="delta-pill <%= ratingCls %>"><span class="delta-glyph"><%= ratingGlyph %></span><span class="delta-num"><%= ratingNum %></span></span></dd>
</div>
<div>
<dt>Predicted next update</dt>
<dd><%= player.predictedRating || '—' %></dd>
</div>
<div>
<dt>Gap to predicted</dt>
<dd><span class="delta-pill <%= predCls %> delta-predicted-pill"><span class="delta-glyph"><%= predGlyph %></span><span class="delta-num"><%= predNum %></span></span></dd>
</div>
<% if (player.stdDev != null && player.rating) { %>
<div>
<dt>Round spread</dt>
<dd>±<%= player.stdDev %></dd>
</div>
<div>
<dt>Rating range</dt>
<dd><%= player.rating - player.stdDev %><%= player.rating + player.stdDev %></dd>
</div>
<% } %>
<% if (player.excludedRoundsCount != null && player.rating) { %>
<div>
<dt>Excluded rounds</dt>
<dd><%= player.excludedRoundsCount %></dd>
</div>
<% } %>
<% if (player.cutoffRating != null && player.rating) { %>
<div>
<dt>Cutoff rating</dt>
<dd><%= player.cutoffRating %></dd>
</div>
<% } %>
</dl>
</div>
</div>
<% }); %>
</div>
<% } %>
+108
View File
@@ -0,0 +1,108 @@
<%
function renderSparkline(values, opts) {
opts = opts || {};
var w = opts.w || 96;
var h = opts.h || 28;
if (!values || values.length < 2) return '';
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>
<% } %>
</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>
<% } %>
</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 target-rating-icon" onclick="openTargetRatingModal(<%= player.pdgaNumber %>)" title="Calculate target rating" aria-label="Calculate target rating">
<i class="fas fa-bullseye"></i>
</button>
<button class="icon-btn icon-chev" onclick="togglePlayerHistory(<%= player.pdgaNumber %>)" title="Expand row" aria-label="Expand">
<i class="fas fa-chevron-down"></i>
</button>
</td>
</tr>
<tr id="history-<%= player.pdgaNumber %>" class="expanded-content">
<td colspan="5" class="expanded-cell">
<div id="history-content-<%= player.pdgaNumber %>" data-loaded="true">
<%- include('player-history', {
pdgaNumber: player.pdgaNumber,
history: player.ratingHistory || [],
player: player
}) %>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
<%- include('ratings-cards', { ratings: ratings }) %>
<% } %>
+83
View File
@@ -0,0 +1,83 @@
<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>
<div class="topbar__mobile">
<div class="topbar__mobile-row1">
<a href="/" class="topbar__mobile-brand">
<span class="topbar__mobile-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__mobile-brand-text">
<span class="topbar__mobile-title">Rating Tracker</span>
<span class="topbar__mobile-sub">Disc golf &middot; unofficial</span>
</span>
</a>
<button
class="topbar__mobile-refresh"
type="button"
hx-post="/api/refresh-all"
hx-vals='{"page": "<%= activePage %>"}'
hx-target="#topbar"
hx-swap="outerHTML"
hx-disabled-elt="this"
title="Refresh all"
aria-label="Refresh all"
>
<span class="topbar__refresh-icon" aria-hidden="true">&#8635;</span>
<span class="topbar__refresh-spinner" aria-hidden="true"></span>
</button>
</div>
<div class="topbar__mobile-row2">
<nav class="topbar__mobile-nav" aria-label="Primary">
<a href="/" class="<%= activePage === 'players' ? 'active' : '' %>">Players</a>
<a href="/courses" class="<%= activePage === 'courses' ? 'active' : '' %>">Courses</a>
</nav>
</div>
</div>
</header>