From 7e5fa6cbf14ab27768ad09b6a6c8f37019994961 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Thu, 19 Feb 2026 08:29:56 +0100 Subject: [PATCH] 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 --- public/js/courses.js | 230 +-------------- public/js/players.js | 451 ++++++++++++------------------ public/js/progress.js | 38 ++- src/routes/courses.js | 30 ++ src/routes/players.js | 37 +++ views/pages/courses.ejs | 11 +- views/pages/index.ejs | 4 +- views/partials/course-layouts.ejs | 71 +++++ views/partials/course-table.ejs | 46 +++ views/partials/layout.ejs | 1 + views/partials/player-history.ejs | 12 + views/partials/ratings-table.ejs | 64 +++++ 12 files changed, 468 insertions(+), 527 deletions(-) create mode 100644 views/partials/course-layouts.ejs create mode 100644 views/partials/course-table.ejs create mode 100644 views/partials/player-history.ejs create mode 100644 views/partials/ratings-table.ejs diff --git a/public/js/courses.js b/public/js/courses.js index bede122..ef557eb 100644 --- a/public/js/courses.js +++ b/public/js/courses.js @@ -1,112 +1,3 @@ -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`); @@ -120,7 +11,7 @@ function toggleAccordion(accordionId) { } } -async function toggleCourseLayouts(courseId) { +function toggleCourseLayouts(courseId) { const layoutsRow = document.getElementById(`layouts-${courseId}`); const layoutsContainer = document.getElementById(`layouts-container-${courseId}`); @@ -135,120 +26,22 @@ async function toggleCourseLayouts(courseId) { 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
'; - } + htmx.ajax('GET', `/partials/course-layouts/${courseId}`, {target: `#layouts-container-${courseId}`, swap: 'innerHTML'}); + layoutsContainer.dataset.loaded = 'true'; } async function scrapeCourses() { const btn = document.getElementById('scrape-courses-btn'); btn.disabled = true; - btn.innerHTML = ' Scraping...'; + btn.textContent = 'Scraping...'; try { - const response = await fetch('/api/scrape-courses', { - method: 'POST' - }); - + const response = await fetch('/api/scrape-courses', { method: 'POST' }); const data = await response.json(); if (data.success) { alert(data.message); - await loadCourses(); - searchCourses(); + htmx.ajax('GET', '/partials/course-table', '#courses-table'); } else { alert('Failed to scrape courses'); } @@ -257,7 +50,7 @@ async function scrapeCourses() { alert('Error scraping courses'); } finally { btn.disabled = false; - btn.innerHTML = ' Scrape Courses'; + btn.textContent = 'Scrape Courses'; } } @@ -266,10 +59,7 @@ async function scrapeLayouts(courseId, courseName) { icon.classList.add('spinning'); try { - const response = await fetch(`/api/scrape-layouts/${courseId}`, { - method: 'POST' - }); - + const response = await fetch(`/api/scrape-layouts/${courseId}`, { method: 'POST' }); const data = await response.json(); if (response.status === 409) { @@ -280,8 +70,8 @@ async function scrapeLayouts(courseId, courseName) { const layoutsRow = document.getElementById(`layouts-${courseId}`); if (layoutsRow.style.display === 'table-row') { - toggleCourseLayouts(courseId); - setTimeout(() => toggleCourseLayouts(courseId), 100); + htmx.ajax('GET', `/partials/course-layouts/${courseId}`, {target: `#layouts-container-${courseId}`, swap: 'innerHTML'}); + layoutsContainer.dataset.loaded = 'true'; } alert(data.message); diff --git a/public/js/players.js b/public/js/players.js index 96c265e..d0c80e6 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -1,90 +1,28 @@ 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 += ` - - - - - - - - - - - - `; +function setupTooltipsAfterSwap() { + document.body.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'ratings-table') { + initRatingsTooltips(); + } + // After player history partial loads, render the chart + const target = event.detail.target; + if (target.id && target.id.startsWith('history-content-')) { + const container = target.querySelector('.chart-container'); + if (container && container.dataset.history) { + try { + const history = JSON.parse(container.dataset.history); + createRatingChart(container, history); + } catch (e) { + console.error('Error rendering chart:', e); + } + } + } }); - - 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; +} +function initRatingsTooltips() { document.querySelectorAll('.predicted-value').forEach(span => { const pdgaNumber = span.dataset.pdga; const stdDev = span.dataset.stddev; @@ -109,47 +47,30 @@ function displayRatings(ratings) { }); } -async function togglePlayerHistory(pdgaNumber) { +function togglePlayerHistory(pdgaNumber) { const historyRow = document.getElementById(`history-${pdgaNumber}`); - const chartContainer = document.getElementById(`chart-${pdgaNumber}`); - + const contentDiv = document.getElementById(`history-content-${pdgaNumber}`); + if (historyRow.style.display === 'table-row') { historyRow.style.display = 'none'; return; } - + historyRow.style.display = 'table-row'; - - if (chartContainer.dataset.loaded === 'true') { + + if (contentDiv.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
'; - } + + 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 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?')) { @@ -167,27 +88,24 @@ async function clearCache() { 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 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 ? + + 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' : + 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'; @@ -224,18 +142,15 @@ async function refreshPlayer(pdgaNumber) { 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 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; @@ -271,27 +186,6 @@ async function refreshRoundHistory(pdgaNumber) { 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); @@ -331,24 +225,16 @@ async function refreshRoundHistory(pdgaNumber) { 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 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
'; - } + 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); @@ -358,61 +244,27 @@ async function refreshRatingHistory(pdgaNumber) { } } -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 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'); @@ -426,8 +278,7 @@ async function showDebugInfo(pdgaNumber) { } function closeDebugModal(event) { - const modal = document.getElementById('debug-modal'); - modal.style.display = 'none'; + document.getElementById('debug-modal').style.display = 'none'; } async function searchAndAddPlayer() { @@ -440,9 +291,9 @@ async function searchAndAddPlayer() { } const button = document.querySelector('.btn-add'); - const originalHTML = button.innerHTML; + const originalText = button.textContent; button.disabled = true; - button.innerHTML = ' Searching...'; + button.textContent = 'Searching...'; try { const response = await fetch(`/api/search-player/${pdgaNumber}`); @@ -466,70 +317,112 @@ async function searchAndAddPlayer() { showErrorModal('Failed to search for player. Please try again.'); } finally { button.disabled = false; - button.innerHTML = originalHTML; + button.textContent = originalText; } } 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'); + document.getElementById('add-player-modal-header').textContent = 'Confirm Player'; - 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 = ` - - - `; + 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'); - const header = document.getElementById('add-player-modal-header'); - const body = document.getElementById('add-player-modal-body'); - const footer = document.getElementById('add-player-modal-footer'); + document.getElementById('add-player-modal-header').textContent = 'Player Not Found'; - header.textContent = 'Player Not Found'; - body.innerHTML = ` -

- ${message} -

-

- Please check the PDGA number and try again. -

- `; - footer.innerHTML = ` - - `; + 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'); - const header = document.getElementById('add-player-modal-header'); - const body = document.getElementById('add-player-modal-body'); - const footer = document.getElementById('add-player-modal-footer'); + document.getElementById('add-player-modal-header').textContent = 'Information'; - header.textContent = 'Information'; - body.innerHTML = ` -

- ${message} -

- `; - footer.innerHTML = ` - - `; + 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'; } @@ -540,19 +433,14 @@ async function confirmAddPlayer() { 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 = ''; + 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' - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pdgaNumber: pendingPlayerData.pdgaNumber }) }); @@ -562,34 +450,39 @@ async function confirmAddPlayer() { throw new Error(data.error || 'Failed to add player'); } - body.innerHTML = ` -

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

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

- ${error.message} -

- `; - footer.innerHTML = ` - - `; + 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) { - const modal = document.getElementById('add-player-modal'); - modal.style.display = 'none'; + document.getElementById('add-player-modal').style.display = 'none'; pendingPlayerData = null; } diff --git a/public/js/progress.js b/public/js/progress.js index 0db252e..4f9b18a 100644 --- a/public/js/progress.js +++ b/public/js/progress.js @@ -3,15 +3,15 @@ function fetchRatingsWithProgress() { 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}%`; @@ -29,18 +29,14 @@ function fetchRatingsWithProgress() { 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.

'; + htmx.ajax('GET', '/partials/ratings-table', '#ratings-table'); eventSource.close(); } }; - + eventSource.onerror = function() { progressSection.style.display = 'none'; - tableDiv.innerHTML = '

Connection error. Please refresh the page.

'; + tableDiv.textContent = 'Connection error. Please refresh the page.'; eventSource.close(); }; } @@ -50,26 +46,26 @@ function loadAllPlayers() { 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 = ''; - + tableDiv.textContent = ''; + 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') { @@ -79,16 +75,16 @@ function loadAllPlayers() { } } else if (data.status === 'complete') { progressSection.style.display = 'none'; - displayRatings(data.ratings); + htmx.ajax('GET', '/partials/ratings-table', '#ratings-table'); eventSource.close(); button.textContent = originalText; button.style.pointerEvents = 'auto'; } }; - + eventSource.onerror = function() { progressSection.style.display = 'none'; - tableDiv.innerHTML = '

Connection error. Please refresh the page.

'; + tableDiv.textContent = 'Connection error. Please refresh the page.'; eventSource.close(); button.textContent = originalText; button.style.pointerEvents = 'auto'; diff --git a/src/routes/courses.js b/src/routes/courses.js index 69dbd01..734f601 100644 --- a/src/routes/courses.js +++ b/src/routes/courses.js @@ -8,6 +8,36 @@ const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEven // Request locking to prevent concurrent scrapes of the same resource const activeScrapes = new Map(); +router.get('/partials/course-table', async (req, res) => { + try { + const allCourses = await getAllCoursesFromDB(); + const query = req.query.q || ''; + let courses = allCourses; + + if (query) { + const q = query.toLowerCase(); + courses = allCourses.filter(c => + c.name.toLowerCase().includes(q) || c.city.toLowerCase().includes(q) + ); + } + + res.render('../partials/course-table', { courses, query, total: allCourses.length }); + } catch (error) { + res.status(500).send('

Error loading courses. Please try again.

'); + } +}); + +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) { + console.error('Error loading course layouts:', error.message); + res.status(500).send('
Error loading layouts
'); + } +}); + router.get('/api/courses', async (req, res) => { try { const courses = await getAllCoursesFromDB(); diff --git a/src/routes/players.js b/src/routes/players.js index 209cf85..5601c30 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -8,6 +8,43 @@ const { launchBrowser } = require('../scrapers/browser'); const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service'); const { calculatePredictedRating } = require('../services/rating-calculator'); +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('

Error loading ratings. Please try again.

'); + } +}); + +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) { + console.error('Failed to save rating history:', dbErr.message); + } + } + + const formattedHistory = (history || []).map(row => ({ + date: row.date, + rating: row.rating, + displayDate: new Date(row.date).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' }) + })); + + res.render('../partials/player-history', { pdgaNumber, history: formattedHistory }); + } catch (error) { + console.error('Error loading player history:', error.message); + res.status(500).send('
Error loading rating history
'); + } +}); + router.get('/api/ratings', async (req, res) => { try { const ratings = await getAllRatingsFromDB(); diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index c8d72ed..91acf63 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -4,11 +4,13 @@ type="text" class="search-input" id="course-search" + name="q" placeholder="Search courses by name or city..." - oninput="searchCourses()" + hx-get="/partials/course-table" + hx-trigger="input changed delay:300ms, search" + hx-target="#courses-table" /> -
-
+
`; %> <%- include('../partials/layout', { @@ -26,6 +28,5 @@ 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 index 171391d..8b4a9f9 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -26,7 +26,7 @@
Preparing to load ratings...
-
+
`; %> <% var modals = ` @@ -59,7 +59,7 @@ activePage: 'players', cssFiles: ['players.css'], jsFiles: ['tooltips.js', 'chart.js', 'progress.js', 'players.js'], - initScript: 'fetchRatingsWithProgress();', + initScript: 'setupTooltipsAfterSwap();', body: body, modals: modals }) %> \ No newline at end of file diff --git a/views/partials/course-layouts.ejs b/views/partials/course-layouts.ejs new file mode 100644 index 0000000..90861a0 --- /dev/null +++ b/views/partials/course-layouts.ejs @@ -0,0 +1,71 @@ +<% if (layouts.length === 0) { %> +
No layouts found. Click the refresh icon to scrape layouts.
+<% } else { + var oneYearAgo = new Date(); + oneYearAgo.setDate(oneYearAgo.getDate() - 365); + + var activeLayouts = []; + var inactiveLayouts = []; + + layouts.forEach(function(layout) { + if (layout.last_played) { + var lastPlayedDate = new Date(layout.last_played); + if (lastPlayedDate >= oneYearAgo) { + activeLayouts.push(layout); + } else { + inactiveLayouts.push(layout); + } + } else { + inactiveLayouts.push(layout); + } + }); +%> +

Layouts:

+ + <% if (activeLayouts.length > 0) { %> + <% activeLayouts.forEach(function(layout) { + var ratingDisplay = layout.mean_rating ? + 'Rating: ' + layout.mean_rating + '' : ''; + var dateDisplay = layout.last_played ? + 'Last played: ' + new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + '' : ''; + %> +
+
+ <%= layout.name %> + <%- dateDisplay %> +
+ Par <%= layout.par %><%- ratingDisplay %> +
+ <% }); %> + <% } %> + + <% if (inactiveLayouts.length > 0) { %> +
+
+ Inactive Layouts (<%= inactiveLayouts.length %>) - Not played in last year + +
+
+ <% inactiveLayouts.forEach(function(layout) { + var ratingDisplay = layout.mean_rating ? + 'Rating: ' + layout.mean_rating + '' : ''; + var dateDisplay = layout.last_played ? + 'Last played: ' + new Date(layout.last_played).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + '' : + 'Never played'; + %> +
+
+ <%= layout.name %> + <%- dateDisplay %> +
+ Par <%= layout.par %><%- ratingDisplay %> +
+ <% }); %> +
+
+ <% } %> + + <% if (activeLayouts.length === 0 && inactiveLayouts.length === 0) { %> +
No layouts found. Click the refresh icon to scrape layouts.
+ <% } %> +<% } %> diff --git a/views/partials/course-table.ejs b/views/partials/course-table.ejs new file mode 100644 index 0000000..93a26d3 --- /dev/null +++ b/views/partials/course-table.ejs @@ -0,0 +1,46 @@ +
+ <% if (typeof query !== 'undefined' && query) { %> + Showing <%= courses.length %> of <%= total %> courses + <% } else { %> + Showing all <%= courses.length %> courses + <% } %> +
+ +<% if (courses.length === 0) { %> +

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

+<% } else { %> + + + + + + + + + + + <% courses.forEach(function(course) { + var lastUpdated = new Date(course.last_updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + %> + + + + + + + + + + <% }); %> + +
Course NameCityLast UpdatedActions
+ <%= course.name %> +
<%= course.city %>
+
<%= course.city %><%= lastUpdated %> + '); event.stopPropagation();" title="Scrape layouts for this course"> +
+
+
Click to load layouts...
+
+
+<% } %> diff --git a/views/partials/layout.ejs b/views/partials/layout.ejs index 0d11232..c3edb14 100644 --- a/views/partials/layout.ejs +++ b/views/partials/layout.ejs @@ -5,6 +5,7 @@ <%= title %> + <% if (typeof cssFiles !== 'undefined') { %> <% cssFiles.forEach(function(file) { %> diff --git a/views/partials/player-history.ejs b/views/partials/player-history.ejs new file mode 100644 index 0000000..9bb0edf --- /dev/null +++ b/views/partials/player-history.ejs @@ -0,0 +1,12 @@ +<% if (history && history.length > 0) { %> +
+
Loading chart...
+
+
+<% } else { %> +
+
No rating history available
+
+<% } %> diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs new file mode 100644 index 0000000..fb8cb82 --- /dev/null +++ b/views/partials/ratings-table.ejs @@ -0,0 +1,64 @@ +<% if (ratings.length === 0) { %> +

No ratings found.

+<% } else { %> + + + + + + + + + + + + + <% ratings.forEach(function(player, index) { + var difference = player.predictedRating && player.rating ? player.predictedRating - player.rating : 0; + var diffText = difference > 0 ? '+' + difference : difference.toString(); + var diffClass = difference > 0 ? 'positive' : difference < 0 ? 'negative' : 'neutral'; + var ratingChangeText = player.ratingChange ? (player.ratingChange > 0 ? '+' + player.ratingChange : player.ratingChange.toString()) : 'N/A'; + var ratingChangeClass = player.ratingChange > 0 ? 'positive' : player.ratingChange < 0 ? 'negative' : 'neutral'; + %> + + + + + + + + + + + + <% }); %> + +
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...
+
+
+<% } %>