From 2ab4869fb9067d027e5eee97c5af2810bf92bdf5 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 12 Aug 2025 12:40:15 +0200 Subject: [PATCH] Add expandable rating history charts with interactive tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement clickable player rows to expand/collapse rating history - Add rating history scraping from PDGA history pages - Create custom SVG line charts showing rating progression over time - Add interactive tooltips with date and rating on hover - Include visual highlights when hovering over data points - Implement anti-flicker tooltip system with delayed hiding - Add large hover areas (12px radius) for better user experience - Show grid lines, axis labels, and responsive chart scaling - Cache rating history data to avoid repeated API calls 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- index.html | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++- server.js | 103 +++++++++++++++++++++++ 2 files changed, 338 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 26e270d..89f0888 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,55 @@ 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; + } + .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; @@ -217,9 +266,9 @@ player.ratingChange < 0 ? 'negative' : 'neutral'; tableHTML += ` - + ${index + 1} - ${player.name} + ${player.name} #${player.pdgaNumber} ${player.rating || 'N/A'} ${ratingChangeText} @@ -228,7 +277,16 @@ ${difference ? diffText : - ``} + ``} + + + + +
Rating History for ${player.name}
+
+
Click to load rating history...
+
+
`; @@ -280,6 +338,180 @@ } } + 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'; + + // Check if chart is already loaded + if (chartContainer.dataset.loaded === 'true') { + return; + } + + // Show loading state + 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
'; + } + } + + 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; + + // Get the tooltip element + const pdgaNumber = container.id.replace('chart-', ''); + const tooltip = document.getElementById(`tooltip-${pdgaNumber}`); + + // Calculate scales + 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); + + // Create SVG + let svg = ``; + + // Background + svg += ``; + + // Chart area background + svg += ``; + + // Grid lines + 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}`; + } + + // Data line + 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}`; + } + }); + + // Draw line + svg += ``; + + // Draw points with enhanced hover areas + points.forEach((point, i) => { + svg += ``; + svg += ``; + }); + + // X-axis labels (show every few dates to avoid crowding) + 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}`; + } + }); + + // Y-axis label + svg += `Rating`; + + svg += ''; + + container.innerHTML = svg; + + // Add event listeners for tooltips after a small delay to ensure DOM is ready + 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) => { + // Clear any pending hide + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + tooltipTimeout = null; + } + + // Hide current tooltip if different + 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`; + + // Highlight the data point + 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) { + // Delay hiding to prevent flicker + tooltipTimeout = setTimeout(() => { + tooltip.style.display = 'none'; + dataPoints[i].setAttribute('r', '4'); + dataPoints[i].setAttribute('fill', '#007bff'); + currentTooltip = null; + }, 100); + } + }); + }); + } + }, 100); + } + fetchRatingsWithProgress(); diff --git a/server.js b/server.js index 2f5fd67..3813a12 100644 --- a/server.js +++ b/server.js @@ -423,6 +423,109 @@ app.get('/api/ratings/progress', (req, res) => { }); }); +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 + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 200) { + resolve(data); + } else { + reject(new Error(`HTTP ${res.statusCode}`)); + } + }); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.setTimeout(30000); + req.end(); + }); +} + +function parseRatingHistory(html) { + const history = []; + + // Find all table rows with rating data + const rowMatches = html.match(/]*>[\s\S]*?<\/tr>/gi); + + if (rowMatches) { + for (const row of rowMatches) { + // Skip header rows and empty rows + if (row.includes(']*>(.*?)<\/td>/gi); + + if (cellMatches && cellMatches.length >= 2) { + const dateText = cellMatches[0].replace(/<[^>]*>/g, '').trim(); + const ratingText = cellMatches[1].replace(/<[^>]*>/g, '').trim(); + + // Parse date (DD-Mon-YYYY format) + 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], // YYYY-MM-DD format + rating: parseInt(ratingText), + displayDate: dateText + }); + } + } + } + } + + // Sort by date (oldest first for chart display) + return history.sort((a, b) => new Date(a.date) - new Date(b.date)); +} + +app.get('/api/rating-history/:pdgaNumber', async (req, res) => { + try { + const { pdgaNumber } = req.params; + console.log(`Fetching rating history for PDGA ${pdgaNumber}...`); + + const html = await fetchRatingHistory(pdgaNumber); + const history = parseRatingHistory(html); + + res.json({ + pdgaNumber: parseInt(pdgaNumber), + history + }); + } catch (error) { + console.error('Error fetching rating history:', error.message); + res.status(500).json({ error: 'Failed to fetch rating history' }); + } +}); + app.post('/api/predicted-rating/:pdgaNumber', async (req, res) => { let browser = null; try {