From 20bbdbbfcf53e0713c120754dac4cf29614215b5 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Wed, 18 Feb 2026 22:32:03 +0100 Subject: [PATCH] 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) --- courses.html | 612 --------------- index.html | 1478 ------------------------------------- package-lock.json | 70 +- package.json | 1 + public/css/courses.css | 144 ++++ public/css/players.css | 348 +++++++++ public/css/shared.css | 163 ++++ public/js/chart.js | 121 +++ public/js/courses.js | 297 ++++++++ public/js/players.js | 595 +++++++++++++++ public/js/progress.js | 101 +++ public/js/tooltips.js | 24 + server.js | 2 + src/routes/pages.js | 10 +- views/pages/courses.ejs | 31 + views/pages/index.ejs | 65 ++ views/partials/layout.ejs | 38 + views/partials/nav.ejs | 4 + 18 files changed, 2010 insertions(+), 2094 deletions(-) delete mode 100644 courses.html delete mode 100644 index.html create mode 100644 public/css/courses.css create mode 100644 public/css/players.css create mode 100644 public/css/shared.css create mode 100644 public/js/chart.js create mode 100644 public/js/courses.js create mode 100644 public/js/players.js create mode 100644 public/js/progress.js create mode 100644 public/js/tooltips.js create mode 100644 views/pages/courses.ejs create mode 100644 views/pages/index.ejs create mode 100644 views/partials/layout.ejs create mode 100644 views/partials/nav.ejs diff --git a/courses.html b/courses.html deleted file mode 100644 index 3ecd07b..0000000 --- a/courses.html +++ /dev/null @@ -1,612 +0,0 @@ - - - - - - PDGA Courses - Sweden - - - - -
-

PDGA Courses - Sweden

- - - -
- -
-
- -
- -
- - -
-
- - - - \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index 8cb587a..0000000 --- a/index.html +++ /dev/null @@ -1,1478 +0,0 @@ - - - - - - PDGA Ratings - - - - -
-

PDGA Player Ratings

-
- Player Ratings - Courses -
- - -
-

Add Yourself to Tracked Players

-
- - -
-
-
- Load All - -
- - -
-
- - -
-
- -
Prediction Calculation Details
-
Loading...
-
-
- - - - - - - \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9d9d098..a5bc3bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "pdga-ratings", "version": "1.0.0", "dependencies": { + "ejs": "^4.0.1", "express": "^4.18.2", "fs": "^0.0.1-security", "puppeteer": "^21.0.0", @@ -261,13 +262,18 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/b4a": { "version": "1.6.7", "license": "Apache-2.0" }, "node_modules/balanced-match": { "version": "1.0.2", - "devOptional": true, "license": "MIT" }, "node_modules/bare-events": { @@ -745,6 +751,21 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/ejs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-4.0.1.tgz", + "integrity": "sha512-krvQtxc0btwSm/nvnt1UpnaFDFVJpJ0fdckmALpCgShsr/iGYHTnJiUliZTgmzq/UxTX33TtOQVKaNigMQp/6Q==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.9.1" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "license": "MIT" @@ -1000,6 +1021,36 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "dev": true, @@ -1549,6 +1600,23 @@ "license": "ISC", "optional": true }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" diff --git a/package.json b/package.json index 8debd2c..2cf3ff4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "nodemon server.js" }, "dependencies": { + "ejs": "^4.0.1", "express": "^4.18.2", "fs": "^0.0.1-security", "puppeteer": "^21.0.0", diff --git a/public/css/courses.css b/public/css/courses.css new file mode 100644 index 0000000..4c30da0 --- /dev/null +++ b/public/css/courses.css @@ -0,0 +1,144 @@ +/* Course page styles */ + +.container { + max-width: 1000px; +} + +.nav-links { + text-align: center; + margin-bottom: 20px; +} + +.nav-links a { + margin: 0 15px; + color: #007bff; + text-decoration: none; + font-weight: bold; +} + +.nav-links a:hover { + text-decoration: underline; +} + +.controls { + text-align: right; + margin-bottom: 20px; +} + +.btn { + margin-left: 10px; +} + +.search-container { + margin-bottom: 20px; + text-align: center; +} + +.search-input { + width: 100%; + max-width: 400px; + padding: 10px 15px; + font-size: 16px; + border: 2px solid #ddd; + border-radius: 4px; + outline: none; + transition: border-color 0.2s; +} + +.search-input:focus { + border-color: #007bff; +} + +.search-results-info { + text-align: center; + margin: 10px 0; + color: #666; + font-size: 14px; +} + +.layouts-container { + padding: 15px; +} + +.layout-item { + padding: 10px; + margin: 5px 0; + background-color: white; + border-radius: 4px; + border: 1px solid #dee2e6; + display: flex; + justify-content: space-between; + align-items: center; +} + +.layout-name { + font-weight: bold; + color: #333; +} + +.layout-par { + color: #007bff; + font-weight: bold; +} + +.no-layouts { + text-align: center; + color: #999; + font-style: italic; + padding: 20px; +} + +.inactive-layouts-accordion { + margin-top: 15px; + border: 1px solid #dee2e6; + border-radius: 4px; + background-color: #f8f9fa; +} + +.accordion-header { + padding: 12px 15px; + cursor: pointer; + user-select: none; + display: flex; + justify-content: space-between; + align-items: center; + background-color: #e9ecef; + border-radius: 4px; + transition: background-color 0.2s; +} + +.accordion-header:hover { + background-color: #dee2e6; +} + +.accordion-header-text { + font-weight: 600; + color: #6c757d; + font-size: 14px; +} + +.accordion-icon { + transition: transform 0.3s; + color: #6c757d; +} + +.accordion-icon.expanded { + transform: rotate(180deg); +} + +.accordion-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; + padding: 0 10px; +} + +.accordion-content.expanded { + max-height: 2000px; + padding: 10px; + transition: max-height 0.5s ease-in; +} + +.layout-item.inactive { + opacity: 0.7; +} diff --git a/public/css/players.css b/public/css/players.css new file mode 100644 index 0000000..100b61b --- /dev/null +++ b/public/css/players.css @@ -0,0 +1,348 @@ +/* Player ratings page styles */ + +.progress-container { + width: 100%; + background-color: #f0f0f0; + border-radius: 10px; + padding: 3px; + margin: 20px 0; +} + +.progress-bar { + width: 0%; + height: 30px; + background-color: #007bff; + border-radius: 8px; + text-align: center; + line-height: 30px; + color: white; + font-weight: bold; + transition: width 0.3s ease; +} + +.progress-text { + text-align: center; + margin: 10px 0; + font-size: 16px; + color: #666; +} + +.mobile-only { + display: none; +} + +@media (max-width: 768px) { + .mobile-only { + display: block; + } + + .player-name { + font-weight: bold; + } + + .chart-container { + height: 250px; + margin: 5px 0; + } + + .chart-title { + font-size: 14px; + } +} + +.chart-container { + width: 100%; + height: 300px; + margin: 10px 0; + border: 1px solid #ddd; + border-radius: 4px; + background: white; +} + +.chart-title { + text-align: center; + font-weight: bold; + margin-bottom: 10px; + color: #333; +} + +.loading-chart { + display: flex; + justify-content: center; + align-items: center; + height: 200px; + color: #666; +} + +.chart-tooltip { + position: fixed; + background-color: rgba(0, 0, 0, 0.9); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 10000; + display: none; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.2); +} + +.rating { + font-weight: bold; + color: #007bff; +} + +.std-dev-tooltip { + position: absolute; + background-color: rgba(0, 0, 0, 0.9); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 10000; + display: none; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.2); + font-weight: normal; +} + +.predicted-value { + position: relative; + display: inline-block; +} + +.pdga-number { + color: #6c757d; + font-size: 14px; +} + +.difference { + font-weight: bold; +} + +.positive { + color: #28a745; +} + +.negative { + color: #dc3545; +} + +.neutral { + color: #6c757d; +} + +.rating-change { + font-weight: bold; + font-size: 14px; +} + +.refresh-section { + display: inline-flex; + align-items: center; +} + +.debug-icon:hover { + opacity: 1 !important; + color: #007bff !important; + transform: scale(1.1); +} + +.debug-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 10001; + display: none; + justify-content: center; + align-items: center; +} + +.debug-content { + background: white; + border-radius: 8px; + padding: 20px; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.debug-header { + font-weight: bold; + font-size: 18px; + margin-bottom: 15px; + color: #333; + border-bottom: 2px solid #007bff; + padding-bottom: 10px; +} + +.debug-log { + font-family: 'Courier New', monospace; + background-color: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 4px; + padding: 15px; + font-size: 12px; + line-height: 1.4; + white-space: pre-line; + color: #495057; +} + +.debug-close { + position: absolute; + top: 10px; + right: 15px; + font-size: 24px; + color: #999; + cursor: pointer; + background: none; + border: none; +} + +.debug-close:hover { + color: #333; +} + +.add-player-section { + background-color: #f8f9fa; + border: 2px solid #007bff; + border-radius: 8px; + padding: 20px; + margin-bottom: 30px; + text-align: center; +} + +.add-player-section h3 { + margin-top: 0; + margin-bottom: 15px; + color: #333; + font-size: 18px; +} + +.add-player-form { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.pdga-input { + padding: 10px 15px; + font-size: 16px; + border: 2px solid #ddd; + border-radius: 4px; + outline: none; + width: 250px; + transition: border-color 0.2s; +} + +.pdga-input:focus { + border-color: #007bff; +} + +.btn-add { + background-color: #28a745; +} + +.btn-add:hover { + background-color: #218838; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 10001; + display: none; + justify-content: center; + align-items: center; +} + +.modal-content { + background: white; + border-radius: 8px; + padding: 0; + max-width: 500px; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + position: relative; +} + +.modal-header { + font-weight: bold; + font-size: 20px; + padding: 20px; + color: #333; + border-bottom: 2px solid #007bff; +} + +.modal-body { + padding: 20px; + font-size: 16px; + color: #495057; + line-height: 1.6; +} + +.modal-footer { + padding: 15px 20px; + border-top: 1px solid #e9ecef; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.modal-close { + position: absolute; + top: 10px; + right: 15px; + font-size: 28px; + color: #999; + cursor: pointer; + background: none; + border: none; + line-height: 1; +} + +.modal-close:hover { + color: #333; +} + +.btn-cancel { + background-color: #6c757d; +} + +.btn-cancel:hover { + background-color: #5a6268; +} + +.btn-confirm { + background-color: #28a745; +} + +.btn-confirm:hover { + background-color: #218838; +} + +@media (max-width: 768px) { + .add-player-section { + padding: 15px; + } + .add-player-form { + flex-direction: column; + } + .pdga-input { + width: 100%; + } +} diff --git a/public/css/shared.css b/public/css/shared.css new file mode 100644 index 0000000..2a0a42f --- /dev/null +++ b/public/css/shared.css @@ -0,0 +1,163 @@ +/* Shared styles for PDGA Ratings app */ + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 20px; + background-color: #f5f5f5; +} + +@media (max-width: 768px) { + body { + padding: 10px; + } +} + +.container { + max-width: 800px; + margin: 0 auto; + background: white; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + overflow-x: auto; +} + +@media (max-width: 768px) { + .container { + padding: 15px; + border-radius: 0; + box-shadow: none; + } +} + +h1 { + color: #333; + text-align: center; + margin-bottom: 30px; +} + +@media (max-width: 768px) { + h1 { + font-size: 24px; + margin-bottom: 20px; + } +} + +.loading { + text-align: center; + padding: 20px; + font-size: 18px; + color: #666; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + font-size: 14px; +} + +th, td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +@media (max-width: 768px) { + table { + font-size: 12px; + } + th, td { + padding: 8px 4px; + } + .mobile-hide { + display: none; + } +} + +th { + background-color: #f8f9fa; + font-weight: bold; + color: #495057; +} + +tr:hover { + background-color: #f5f5f5; +} + +.expandable-row { + cursor: pointer; +} + +.expandable-row:hover { + background-color: #e3f2fd; +} + +.expanded-content { + display: none; + background-color: #f8f9fa; + border-top: 2px solid #007bff; +} + +.expanded-content td { + padding: 20px; +} + +a { + color: #007bff; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.btn { + padding: 8px 16px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.btn:hover { + background-color: #0056b3; +} + +.btn:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +.refresh-icon { + cursor: pointer; + opacity: 0.6; + margin-left: 8px; + font-size: 13px; + color: #6c757d; + transition: all 0.2s ease; + padding: 2px; + border-radius: 3px; +} + +.refresh-icon:hover { + opacity: 1; + color: #007bff; + background-color: rgba(0, 123, 255, 0.1); + transform: scale(1.1); +} + +.refresh-icon.spinning { + animation: spin 1s linear infinite; + color: #007bff; + opacity: 1; + background-color: rgba(0, 123, 255, 0.1); +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/public/js/chart.js b/public/js/chart.js new file mode 100644 index 0000000..7f7da21 --- /dev/null +++ b/public/js/chart.js @@ -0,0 +1,121 @@ +function createRatingChart(container, history) { + const width = container.clientWidth - 40; + const height = 250; + const margin = { top: 20, right: 30, bottom: 40, left: 60 }; + const chartWidth = width - margin.left - margin.right; + const chartHeight = height - margin.top - margin.bottom; + + const pdgaNumber = container.id.replace('chart-', ''); + const tooltip = document.getElementById(`tooltip-${pdgaNumber}`); + + const ratings = history.map(h => h.rating); + const minRating = Math.min(...ratings) - 10; + const maxRating = Math.max(...ratings) + 10; + + const xStep = chartWidth / (history.length - 1 || 1); + const yScale = chartHeight / (maxRating - minRating); + + let svg = ``; + + svg += ``; + svg += ``; + + for (let i = 0; i <= 5; i++) { + const y = margin.top + (i * chartHeight / 5); + const rating = Math.round(maxRating - (i * (maxRating - minRating) / 5)); + svg += ``; + svg += `${rating}`; + } + + let pathData = ''; + const points = []; + + history.forEach((point, i) => { + const x = margin.left + (i * xStep); + const y = margin.top + ((maxRating - point.rating) * yScale); + + points.push({ x, y, rating: point.rating, date: point.displayDate }); + + if (i === 0) { + pathData += `M ${x} ${y}`; + } else { + pathData += ` L ${x} ${y}`; + } + }); + + svg += ``; + + points.forEach((point, i) => { + svg += ``; + svg += ``; + }); + + const labelStep = Math.max(1, Math.floor(history.length / 6)); + history.forEach((point, i) => { + if (i % labelStep === 0 || i === history.length - 1) { + const x = margin.left + (i * xStep); + const date = new Date(point.date).toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + svg += `${date}`; + } + }); + + svg += `Rating`; + + svg += ''; + + container.innerHTML = svg; + + setTimeout(() => { + const svgElement = document.getElementById(`svg-${pdgaNumber}`); + + if (svgElement) { + const hoverAreas = svgElement.querySelectorAll('.hover-area'); + const dataPoints = svgElement.querySelectorAll('.data-point'); + + let currentTooltip = null; + let tooltipTimeout = null; + + hoverAreas.forEach((area, i) => { + area.addEventListener('mouseenter', (e) => { + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + tooltipTimeout = null; + } + + if (currentTooltip !== null && currentTooltip !== i) { + dataPoints[currentTooltip].setAttribute('r', '4'); + dataPoints[currentTooltip].setAttribute('fill', '#007bff'); + } + + currentTooltip = i; + const point = points[i]; + tooltip.innerHTML = `${point.date}
Rating: ${point.rating}`; + tooltip.style.display = 'block'; + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + + dataPoints[i].setAttribute('r', '6'); + dataPoints[i].setAttribute('fill', '#0056b3'); + }); + + area.addEventListener('mousemove', (e) => { + if (currentTooltip === i) { + tooltip.style.left = `${e.clientX + 15}px`; + tooltip.style.top = `${e.clientY - 35}px`; + } + }); + + area.addEventListener('mouseleave', () => { + if (currentTooltip === i) { + tooltipTimeout = setTimeout(() => { + tooltip.style.display = 'none'; + dataPoints[i].setAttribute('r', '4'); + dataPoints[i].setAttribute('fill', '#007bff'); + currentTooltip = null; + }, 100); + } + }); + }); + } + }, 100); +} diff --git a/public/js/courses.js b/public/js/courses.js new file mode 100644 index 0000000..bede122 --- /dev/null +++ b/public/js/courses.js @@ -0,0 +1,297 @@ +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 = '

Error loading courses. Please try again.

'; + } +} + +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 = '

No courses found. Click "Scrape Courses" to load Swedish courses from PDGA.

'; + return; + } + + let tableHTML = ` + + + + + + + + + + + `; + + courses.forEach(course => { + const lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + tableHTML += ` + + + + + + + + + + `; + }); + + tableHTML += ` + +
Course NameCityLast UpdatedActions
+ ${course.name} +
${course.city}
+
${course.city}${lastUpdated} + +
+
+
Click to load layouts...
+
+
+ `; + + 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'; + + if (layoutsContainer.dataset.loaded === 'true') { + return; + } + + layoutsContainer.innerHTML = '
Loading layouts...
'; + + try { + const response = await fetch(`/api/layouts/${courseId}`); + const layouts = await response.json(); + + if (layouts.length > 0) { + const oneYearAgo = new Date(); + oneYearAgo.setDate(oneYearAgo.getDate() - 365); + + 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 { + inactiveLayouts.push(layout); + } + }); + + let layoutsHTML = '

Layouts:

'; + + if (activeLayouts.length > 0) { + activeLayouts.forEach(layout => { + const ratingDisplay = layout.mean_rating ? + `Rating: ${layout.mean_rating}` : + ''; + const dateDisplay = layout.last_played ? + `Last played: ${new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}` : + ''; + layoutsHTML += ` +
+
+ ${layout.name} + ${dateDisplay} +
+ Par ${layout.par}${ratingDisplay} +
+ `; + }); + } + + if (inactiveLayouts.length > 0) { + const accordionId = `accordion-${courseId}`; + layoutsHTML += ` +
+
+ Inactive Layouts (${inactiveLayouts.length}) - Not played in last year + +
+
+ `; + + inactiveLayouts.forEach(layout => { + const ratingDisplay = layout.mean_rating ? + `Rating: ${layout.mean_rating}` : + ''; + const dateDisplay = layout.last_played ? + `Last played: ${new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}` : + `Never played`; + layoutsHTML += ` +
+
+ ${layout.name} + ${dateDisplay} +
+ Par ${layout.par}${ratingDisplay} +
+ `; + }); + + layoutsHTML += ` +
+
+ `; + } + + if (activeLayouts.length === 0 && inactiveLayouts.length === 0) { + layoutsHTML = '
No layouts found. Click the refresh icon to scrape layouts.
'; + } + + layoutsContainer.innerHTML = layoutsHTML; + layoutsContainer.dataset.loaded = 'true'; + } else { + layoutsContainer.innerHTML = '
No layouts found. Click the refresh icon to scrape layouts.
'; + } + } catch (error) { + console.error('Error loading layouts:', error); + layoutsContainer.innerHTML = '
Error loading layouts
'; + } +} + +async function scrapeCourses() { + const btn = document.getElementById('scrape-courses-btn'); + btn.disabled = true; + btn.innerHTML = ' Scraping...'; + + try { + const response = await fetch('/api/scrape-courses', { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + alert(data.message); + await loadCourses(); + searchCourses(); + } else { + alert('Failed to scrape courses'); + } + } catch (error) { + console.error('Error scraping courses:', error); + alert('Error scraping courses'); + } finally { + btn.disabled = false; + btn.innerHTML = ' Scrape Courses'; + } +} + +async function scrapeLayouts(courseId, courseName) { + const icon = document.querySelector(`#row-${courseId} .refresh-icon`); + icon.classList.add('spinning'); + + try { + const response = await fetch(`/api/scrape-layouts/${courseId}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (response.status === 409) { + alert(data.message || 'Scrape already in progress for this course. Please wait.'); + } else if (data.success) { + const layoutsContainer = document.getElementById(`layouts-container-${courseId}`); + layoutsContainer.dataset.loaded = 'false'; + + const layoutsRow = document.getElementById(`layouts-${courseId}`); + if (layoutsRow.style.display === 'table-row') { + toggleCourseLayouts(courseId); + 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'); + } +} diff --git a/public/js/players.js b/public/js/players.js new file mode 100644 index 0000000..96c265e --- /dev/null +++ b/public/js/players.js @@ -0,0 +1,595 @@ +let cachedDebugInfo = {}; +let pendingPlayerData = null; + +function displayRatings(ratings) { + const tableDiv = document.getElementById('ratings-table'); + + if (ratings.length === 0) { + tableDiv.innerHTML = '

No ratings found.

'; + return; + } + + let tableHTML = ` + + + + + + + + + + + + + `; + + ratings.forEach((player, index) => { + const difference = player.predictedRating && player.rating ? + player.predictedRating - player.rating : 0; + const diffText = difference > 0 ? `+${difference}` : difference.toString(); + const diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral'; + + const ratingChangeText = player.ratingChange ? + (player.ratingChange > 0 ? `+${player.ratingChange}` : player.ratingChange.toString()) : 'N/A'; + const ratingChangeClass = player.ratingChange > 0 ? 'positive' : + player.ratingChange < 0 ? 'negative' : 'neutral'; + + tableHTML += ` + + + + + + + + + + + + `; + }); + + tableHTML += ` + +
RankPlayer NamePDGA #RatingChangePredicted
${index + 1} + ${player.name} +
PDGA #${player.pdgaNumber}
+
#${player.pdgaNumber} +
+ ${player.rating || 'Click refresh'} + +
+
${ratingChangeText}
+
+
${ratingChangeText} +
+ ${player.predictedRating || 'N/A'} + + +
+
+
+
+
+ Rating History for ${player.name} + +
+
+
+
Click to load rating history...
+
+
+
+ `; + + tableDiv.innerHTML = tableHTML; + + document.querySelectorAll('.predicted-value').forEach(span => { + const pdgaNumber = span.dataset.pdga; + const stdDev = span.dataset.stddev; + const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`); + + if (stdDev && tooltip) { + setupTooltip(span, tooltip, () => `Standard Deviation: \u00b1${stdDev}`); + } + }); + + document.querySelectorAll('.rating-value').forEach(span => { + const pdgaNumber = span.dataset.pdga; + const rating = parseInt(span.dataset.rating); + const stdDev = parseInt(span.dataset.stddev); + const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); + + if (rating && stdDev && tooltip) { + const minRating = rating - stdDev; + const maxRating = rating + stdDev; + setupTooltip(span, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`); + } + }); +} + +async function togglePlayerHistory(pdgaNumber) { + const historyRow = document.getElementById(`history-${pdgaNumber}`); + const chartContainer = document.getElementById(`chart-${pdgaNumber}`); + + if (historyRow.style.display === 'table-row') { + historyRow.style.display = 'none'; + return; + } + + historyRow.style.display = 'table-row'; + + if (chartContainer.dataset.loaded === 'true') { + return; + } + + chartContainer.innerHTML = '
Loading rating history...
'; + + try { + const response = await fetch(`/api/rating-history/${pdgaNumber}`); + const data = await response.json(); + + if (data.history && data.history.length > 0) { + createRatingChart(chartContainer, data.history); + chartContainer.dataset.loaded = 'true'; + } else { + chartContainer.innerHTML = '
No rating history available
'; + } + } catch (error) { + console.error('Error loading rating history:', error); + chartContainer.innerHTML = '
Error loading rating history
'; + } +} + +async function clearCache() { + try { + const response = await fetch('/api/clear-cache', { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + alert(data.message); + if (confirm('Reload page to fetch fresh data?')) { + location.reload(); + } + } else { + alert('Failed to clear cache'); + } + } catch (error) { + console.error('Error clearing cache:', error); + alert('Error clearing cache'); + } +} + +async function refreshPlayer(pdgaNumber) { + const icon = document.querySelector(`#row-${pdgaNumber} .rating .refresh-icon`); + icon.classList.add('spinning'); + + try { + const response = await fetch(`/api/refresh-player/${pdgaNumber}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + const row = document.getElementById(`row-${pdgaNumber}`); + const ratingCell = row.querySelector('.rating'); + const ratingChangeCell = row.querySelector('.rating-change'); + + const nameLink = row.querySelector('.player-name a'); + nameLink.textContent = data.player.name; + + const ratingChangeText = data.player.ratingChange ? + (data.player.ratingChange > 0 ? `+${data.player.ratingChange}` : data.player.ratingChange.toString()) : 'N/A'; + const ratingChangeClass = data.player.ratingChange > 0 ? 'positive' : + data.player.ratingChange < 0 ? 'negative' : 'neutral'; + + const ratingValue = ratingCell.querySelector('.rating-value'); + if (ratingValue) { + ratingValue.textContent = data.player.rating || 'N/A'; + ratingValue.dataset.rating = data.player.rating || ''; + + const stdDev = parseInt(ratingValue.dataset.stddev); + const rating = parseInt(data.player.rating); + const tooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); + + if (rating && stdDev && tooltip) { + const minRating = rating - stdDev; + const maxRating = rating + stdDev; + replaceWithTooltip(ratingValue, tooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`); + } + } + + if (ratingChangeCell) ratingChangeCell.textContent = ratingChangeText; + if (ratingChangeCell) ratingChangeCell.className = `rating-change ${ratingChangeClass} mobile-hide`; + + const mobileChange = ratingCell.querySelector('.mobile-only.rating-change'); + if (mobileChange) { + mobileChange.textContent = ratingChangeText; + mobileChange.className = `mobile-only rating-change ${ratingChangeClass}`; + } + } + } catch (error) { + console.error('Error refreshing player:', error); + alert('Failed to refresh player data'); + } finally { + icon.classList.remove('spinning'); + } +} + +async function refreshRoundHistory(pdgaNumber) { + const icon = document.querySelector(`#predicted-${pdgaNumber} .refresh-icon`); + icon.classList.add('spinning'); + + try { + const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(JSON.stringify(data)); + } + + if (data.success) { + if (data.debugLog) { + cachedDebugInfo[pdgaNumber] = data.debugLog; + } + + const predictedCell = document.getElementById(`predicted-${pdgaNumber}`); + if (predictedCell) { + const predictedValue = predictedCell.querySelector('.predicted-value'); + if (predictedValue) { + predictedValue.textContent = data.predictedRating || 'N/A'; + predictedValue.dataset.stddev = data.stdDev || ''; + + const tooltip = document.getElementById(`tooltip-stddev-${pdgaNumber}`); + if (data.stdDev && tooltip) { + replaceWithTooltip(predictedValue, tooltip, () => `Standard Deviation: \u00b1${data.stdDev}`); + } + } + } + + const row = document.getElementById(`row-${pdgaNumber}`); + const ratingCell = row.querySelector('.rating'); + const ratingValue = ratingCell.querySelector('.rating-value'); + if (ratingValue && data.stdDev) { + ratingValue.dataset.stddev = data.stdDev; + + const rating = parseInt(ratingValue.dataset.rating); + const stdDev = parseInt(data.stdDev); + const ratingTooltip = document.getElementById(`tooltip-rating-${pdgaNumber}`); + + if (rating && stdDev && ratingTooltip) { + const minRating = rating - stdDev; + const maxRating = rating + stdDev; + replaceWithTooltip(ratingValue, ratingTooltip, () => `Rating Range: ${minRating} - ${maxRating} (\u00b1${stdDev})`); + } + } + + const diffCell = document.getElementById(`diff-${pdgaNumber}`); + if (diffCell) { + const currentRatingElement = document.querySelector(`#row-${pdgaNumber} .rating .refresh-section`); + if (currentRatingElement && currentRatingElement.firstChild) { + const currentRatingText = currentRatingElement.firstChild.textContent; + const currentRating = parseInt(currentRatingText); + + if (data.predictedRating && currentRating && !isNaN(currentRating)) { + const difference = data.predictedRating - currentRating; + const diffText = difference > 0 ? `+${difference}` : difference.toString(); + const diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral'; + + diffCell.className = `difference ${diffClass}`; + diffCell.textContent = diffText; + } else { + diffCell.innerHTML = 'Use refresh'; + } + } + } + + } + } catch (error) { + console.error('Error refreshing round history:', error); + + let errorMessage = 'Failed to refresh prediction data'; + let errorDetails = ''; + + try { + const errorData = JSON.parse(error.message); + errorMessage = errorData.error || errorMessage; + + if (errorData.message) { + errorDetails = errorData.message; + } else { + errorDetails = errorData.details || ''; + if (errorData.suggestion) { + errorDetails += '\n\nSuggestion: ' + errorData.suggestion; + } + if (errorData.errorType) { + errorDetails += '\n\nError Type: ' + errorData.errorType; + } + if (errorData.timestamp) { + errorDetails += '\n\nTime: ' + new Date(errorData.timestamp).toLocaleString(); + } + } + } catch (parseError) { + errorDetails = error.message; + } + + const fullMessage = errorDetails ? errorMessage + '\n\n' + errorDetails : errorMessage; + alert(fullMessage); + } finally { + icon.classList.remove('spinning'); + } +} + +async function refreshRatingHistory(pdgaNumber) { + const icon = document.querySelector(`#history-${pdgaNumber} .chart-title .refresh-icon`); + icon.classList.add('spinning'); + + try { + const response = await fetch(`/api/refresh-rating-history/${pdgaNumber}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + const chartContainer = document.getElementById(`chart-${pdgaNumber}`); + chartContainer.dataset.loaded = 'false'; + + if (data.history && data.history.length > 0) { + createRatingChart(chartContainer, data.history); + chartContainer.dataset.loaded = 'true'; + } else { + chartContainer.innerHTML = '
No rating history available
'; + } + } + } catch (error) { + console.error('Error refreshing rating history:', error); + alert('Failed to refresh rating history'); + } finally { + icon.classList.remove('spinning'); + } +} + +async function refreshAllPlayers() { + const icons = document.querySelectorAll('th .refresh-icon'); + const ratingIcon = icons[0]; + ratingIcon.classList.add('spinning'); + + try { + const ratings = await getAllPlayersFromDB(); + displayRatings(ratings); + } catch (error) { + console.error('Error refreshing all players:', error); + alert('Failed to refresh player data'); + } finally { + ratingIcon.classList.remove('spinning'); + } +} + +async function refreshAllPredictions() { + const icons = document.querySelectorAll('th .refresh-icon'); + const predictedIcon = icons[1]; + predictedIcon.classList.add('spinning'); + + try { + alert('Bulk prediction refresh not implemented yet. Use individual refresh icons.'); + } catch (error) { + console.error('Error refreshing all predictions:', error); + alert('Failed to refresh predictions'); + } finally { + predictedIcon.classList.remove('spinning'); + } +} + +async function showDebugInfo(pdgaNumber) { + const modal = document.getElementById('debug-modal'); + const header = document.getElementById('debug-header'); + const log = document.getElementById('debug-log'); + + const playerNameElement = document.querySelector(`#row-${pdgaNumber} .player-name a`); + const playerName = playerNameElement ? playerNameElement.textContent : `PDGA #${pdgaNumber}`; + + header.textContent = `Prediction Calculation Details - ${playerName}`; + log.textContent = 'Loading calculation details...'; + modal.style.display = 'flex'; + + try { + if (cachedDebugInfo[pdgaNumber]) { + log.textContent = cachedDebugInfo[pdgaNumber].join('\n'); + return; + } + + const response = await fetch(`/api/refresh-round-history/${pdgaNumber}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success && data.debugLog) { + cachedDebugInfo[pdgaNumber] = data.debugLog; + log.textContent = data.debugLog.join('\n'); + } else { + log.textContent = 'No debug information available. Try refreshing the prediction first.'; + } + } catch (error) { + console.error('Error fetching debug info:', error); + log.textContent = 'Error loading debug information. Please try again.'; + } +} + +function closeDebugModal(event) { + const modal = document.getElementById('debug-modal'); + modal.style.display = 'none'; +} + +async function searchAndAddPlayer() { + const input = document.getElementById('pdga-number-input'); + const pdgaNumber = input.value.trim(); + + if (!pdgaNumber) { + alert('Please enter a PDGA number'); + return; + } + + const button = document.querySelector('.btn-add'); + const originalHTML = button.innerHTML; + button.disabled = true; + button.innerHTML = ' Searching...'; + + try { + const response = await fetch(`/api/search-player/${pdgaNumber}`); + const data = await response.json(); + + if (!response.ok) { + showErrorModal(data.error || 'Player not found'); + return; + } + + if (data.alreadyExists) { + showInfoModal(`${data.player.name} is already being tracked!`); + return; + } + + pendingPlayerData = data.player; + showConfirmationModal(data.player); + + } catch (error) { + console.error('Error searching for player:', error); + showErrorModal('Failed to search for player. Please try again.'); + } finally { + button.disabled = false; + button.innerHTML = originalHTML; + } +} + +function showConfirmationModal(player) { + const modal = document.getElementById('add-player-modal'); + const header = document.getElementById('add-player-modal-header'); + const body = document.getElementById('add-player-modal-body'); + const footer = document.getElementById('add-player-modal-footer'); + + header.textContent = 'Confirm Player'; + body.innerHTML = ` +

Is this the correct player you want to add?

+
+ ${player.name}
+ PDGA #${player.pdgaNumber}
+ ${player.rating ? `Current Rating: ${player.rating}` : 'No rating available'} +
+ `; + footer.innerHTML = ` + + + `; + + modal.style.display = 'flex'; +} + +function showErrorModal(message) { + const modal = document.getElementById('add-player-modal'); + const header = document.getElementById('add-player-modal-header'); + const body = document.getElementById('add-player-modal-body'); + const footer = document.getElementById('add-player-modal-footer'); + + header.textContent = 'Player Not Found'; + body.innerHTML = ` +

+ ${message} +

+

+ Please check the PDGA number and try again. +

+ `; + footer.innerHTML = ` + + `; + + modal.style.display = 'flex'; +} + +function showInfoModal(message) { + const modal = document.getElementById('add-player-modal'); + const header = document.getElementById('add-player-modal-header'); + const body = document.getElementById('add-player-modal-body'); + const footer = document.getElementById('add-player-modal-footer'); + + header.textContent = 'Information'; + body.innerHTML = ` +

+ ${message} +

+ `; + footer.innerHTML = ` + + `; + + modal.style.display = 'flex'; +} + +async function confirmAddPlayer() { + if (!pendingPlayerData) { + closeAddPlayerModal(); + return; + } + + const modal = document.getElementById('add-player-modal'); + const body = document.getElementById('add-player-modal-body'); + const footer = document.getElementById('add-player-modal-footer'); + + body.innerHTML = '

Adding player...

'; + footer.innerHTML = ''; + + 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.innerHTML = ` +

+
+ ${data.player.name} has been added successfully! +

+ `; + footer.innerHTML = ` + + `; + + document.getElementById('pdga-number-input').value = ''; + pendingPlayerData = null; + + } catch (error) { + console.error('Error adding player:', error); + body.innerHTML = ` +

+ ${error.message} +

+ `; + footer.innerHTML = ` + + `; + } +} + +function closeAddPlayerModal(event) { + const modal = document.getElementById('add-player-modal'); + modal.style.display = 'none'; + pendingPlayerData = null; +} diff --git a/public/js/progress.js b/public/js/progress.js new file mode 100644 index 0000000..0db252e --- /dev/null +++ b/public/js/progress.js @@ -0,0 +1,101 @@ +function fetchRatingsWithProgress() { + const progressSection = document.getElementById('progress-section'); + const progressBar = document.getElementById('progress-bar'); + const progressText = document.getElementById('progress-text'); + const tableDiv = document.getElementById('ratings-table'); + + progressSection.style.display = 'block'; + tableDiv.innerHTML = ''; + + const eventSource = new EventSource('/api/ratings/progress'); + + eventSource.onmessage = function(event) { + const data = JSON.parse(event.data); + + if (data.status === 'loading') { + const percentage = Math.round((data.current / data.total) * 100); + progressBar.style.width = `${percentage}%`; + progressBar.textContent = `${percentage}%`; + progressText.textContent = `Loading player ${data.current}/${data.total}: PDGA #${data.pdgaNumber}`; + } else if (data.status === 'completed') { + const percentage = Math.round((data.current / data.total) * 100); + progressBar.style.width = `${percentage}%`; + progressBar.textContent = `${percentage}%`; + progressText.textContent = `Loaded ${data.name} (${data.current}/${data.total})`; + } else if (data.status === 'error') { + const percentage = Math.round((data.current / data.total) * 100); + progressBar.style.width = `${percentage}%`; + progressBar.textContent = `${percentage}%`; + progressText.textContent = `Error loading PDGA #${data.pdgaNumber} (${data.current}/${data.total})`; + } else if (data.status === 'complete') { + progressSection.style.display = 'none'; + displayRatings(data.ratings); + eventSource.close(); + } else if (data.status === 'error') { + progressSection.style.display = 'none'; + tableDiv.innerHTML = '

Error loading ratings. Please try again.

'; + eventSource.close(); + } + }; + + eventSource.onerror = function() { + progressSection.style.display = 'none'; + tableDiv.innerHTML = '

Connection error. Please refresh the page.

'; + eventSource.close(); + }; +} + +function loadAllPlayers() { + const button = document.getElementById('load-all-btn'); + const originalText = button.textContent; + button.textContent = 'Loading...'; + button.style.pointerEvents = 'none'; + + try { + const progressSection = document.getElementById('progress-section'); + const progressBar = document.getElementById('progress-bar'); + const progressText = document.getElementById('progress-text'); + const tableDiv = document.getElementById('ratings-table'); + + progressSection.style.display = 'block'; + tableDiv.innerHTML = ''; + + const eventSource = new EventSource('/api/load-all-players'); + + eventSource.onmessage = function(event) { + const data = JSON.parse(event.data); + + if (data.status === 'loading' || data.status === 'completed' || data.status === 'error') { + const percentage = Math.round((data.current / data.total) * 100); + progressBar.style.width = `${percentage}%`; + progressBar.textContent = `${percentage}%`; + + if (data.status === 'loading') { + progressText.textContent = `Loading player ${data.current}/${data.total}: PDGA #${data.pdgaNumber}`; + } else if (data.status === 'completed') { + progressText.textContent = `Loaded ${data.name} (${data.current}/${data.total})`; + } else if (data.status === 'error') { + progressText.textContent = `Error loading PDGA #${data.pdgaNumber} (${data.current}/${data.total})`; + } + } else if (data.status === 'complete') { + progressSection.style.display = 'none'; + displayRatings(data.ratings); + eventSource.close(); + button.textContent = originalText; + button.style.pointerEvents = 'auto'; + } + }; + + eventSource.onerror = function() { + progressSection.style.display = 'none'; + tableDiv.innerHTML = '

Connection error. Please refresh the page.

'; + eventSource.close(); + button.textContent = originalText; + button.style.pointerEvents = 'auto'; + }; + } catch (error) { + console.error('Error loading all players:', error); + button.textContent = originalText; + button.style.pointerEvents = 'auto'; + } +} diff --git a/public/js/tooltips.js b/public/js/tooltips.js new file mode 100644 index 0000000..6eab87e --- /dev/null +++ b/public/js/tooltips.js @@ -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; +} diff --git a/server.js b/server.js index cff026f..f5df138 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,8 @@ const pageRoutes = require('./src/routes/pages'); const app = express(); const PORT = 3000; +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views/pages')); app.use(express.static('public')); app.use(express.json()); diff --git a/src/routes/pages.js b/src/routes/pages.js index 33cfb9b..2eb677f 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -1,13 +1,17 @@ const express = require('express'); -const path = require('path'); const router = express.Router(); router.get('/', (req, res) => { - res.sendFile(path.join(__dirname, '../../index.html')); + res.render('index'); }); +router.get('/courses', (req, res) => { + res.render('courses'); +}); + +// Keep old URL working router.get('/courses.html', (req, res) => { - res.sendFile(path.join(__dirname, '../../courses.html')); + res.redirect('/courses'); }); module.exports = router; diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs new file mode 100644 index 0000000..c8d72ed --- /dev/null +++ b/views/pages/courses.ejs @@ -0,0 +1,31 @@ +<% var body = ` +
+ +
+
+ +
+ +
+ + +
+`; %> + +<%- include('../partials/layout', { + title: 'PDGA Courses - Sweden', + heading: 'PDGA Courses - Sweden', + activePage: 'courses', + cssFiles: ['courses.css'], + jsFiles: ['courses.js'], + initScript: 'loadCourses();', + body: body +}) %> \ No newline at end of file diff --git a/views/pages/index.ejs b/views/pages/index.ejs new file mode 100644 index 0000000..171391d --- /dev/null +++ b/views/pages/index.ejs @@ -0,0 +1,65 @@ +<% var body = ` + +
+

Add Yourself to Tracked Players

+
+ + +
+
+
+ Load All + +
+ + +
+`; %> + +<% var modals = ` + +
+
+ +
Prediction Calculation Details
+
Loading...
+
+
+ + + +`; %> + +<%- include('../partials/layout', { + title: 'PDGA Ratings', + heading: 'PDGA Player Ratings', + activePage: 'players', + cssFiles: ['players.css'], + jsFiles: ['tooltips.js', 'chart.js', 'progress.js', 'players.js'], + initScript: 'fetchRatingsWithProgress();', + body: body, + modals: modals +}) %> \ No newline at end of file diff --git a/views/partials/layout.ejs b/views/partials/layout.ejs new file mode 100644 index 0000000..0d11232 --- /dev/null +++ b/views/partials/layout.ejs @@ -0,0 +1,38 @@ + + + + + + <%= title %> + + + <% if (typeof cssFiles !== 'undefined') { %> + <% cssFiles.forEach(function(file) { %> + + <% }); %> + <% } %> + + +
+

<%= heading %>

+ <%- include('../partials/nav') %> + <%- body %> +
+ + <% if (typeof modals !== 'undefined') { %> + <%- modals %> + <% } %> + + <% if (typeof jsFiles !== 'undefined') { %> + <% jsFiles.forEach(function(file) { %> + + <% }); %> + <% } %> + + <% if (typeof initScript !== 'undefined') { %> + + <% } %> + + \ No newline at end of file diff --git a/views/partials/nav.ejs b/views/partials/nav.ejs new file mode 100644 index 0000000..af4210b --- /dev/null +++ b/views/partials/nav.ejs @@ -0,0 +1,4 @@ +
+ Player Ratings + Courses +
\ No newline at end of file