diff --git a/public/css/shared.css b/public/css/shared.css index 1d38fc3..c619efc 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -364,14 +364,99 @@ tr:last-child td { display: none; } +@keyframes expandIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.expanded-content.is-open { + animation: expandIn 0.2s ease; +} + .expanded-content td { padding: 0; - background: var(--surface-2); + background: color-mix(in oklab, var(--accent) 4%, var(--paper-2)); border-top: 2px solid var(--accent); } .expanded-cell { - padding: 20px !important; + padding: 22px 28px 28px !important; +} + +tr.row-open { + box-shadow: inset 3px 0 0 var(--accent); +} + +/* ── Expanded Row Detail Grid ─────────────────── */ + +.player-detail { + display: grid; + grid-template-columns: 240px 1fr; + gap: 28px; + align-items: start; +} + +@media (max-width: 880px) { + .player-detail { + grid-template-columns: 1fr; + } +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr; + margin: 0; + padding: 0; + list-style: none; +} + +.detail-grid > div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + padding: 8px 0; + border-bottom: 1px dashed var(--line); +} + +.detail-grid > div:last-child { + border-bottom: none; +} + +.detail-grid dt { + color: var(--ink-2); + font-size: 13px; + font-weight: 400; + flex-shrink: 0; +} + +.detail-grid dd { + color: var(--ink); + font-size: 13px; + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + margin: 0; + text-align: right; +} + +.player-chart { + width: 100%; + max-width: 880px; +} + +.link-btn { + background: none; + border: none; + padding: 0; + font-family: var(--font-sans); + font-size: 13px; + cursor: pointer; + color: var(--accent); +} + +.link-btn:disabled { + color: var(--ink-3); + cursor: default; } /* ── Links ────────────────────────────────────── */ diff --git a/public/js/chart.js b/public/js/chart.js index abbf382..09cc5d4 100644 --- a/public/js/chart.js +++ b/public/js/chart.js @@ -1,150 +1,138 @@ function createRatingChart(container, history) { - const width = container.clientWidth; - const height = 280; - const margin = { top: 24, right: 20, bottom: 44, left: 56 }; - 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); - - // Build points array - const points = []; - let pathData = ''; - - 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 }); - pathData += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`; - }); - - // Area fill path (close to bottom) - const areaPath = pathData + - ` L ${points[points.length - 1].x} ${margin.top + chartHeight}` + - ` L ${points[0].x} ${margin.top + chartHeight} Z`; - - let svg = ``; - - // Defs: gradient + glow - svg += ` - - - - - - - - - `; - - // Background - svg += ``; - - // Grid lines - const gridCount = 5; - for (let i = 0; i <= gridCount; i++) { - const y = margin.top + (i * chartHeight / gridCount); - const rating = Math.round(maxRating - (i * (maxRating - minRating) / gridCount)); - - svg += ``; - svg += `${rating}`; + if (!history || history.length === 0) { + container.textContent = ''; + var empty = document.createElement('div'); + empty.className = 'loading-chart'; + empty.textContent = 'No rating history available'; + container.appendChild(empty); + return; } - // Area fill - svg += ``; + var W = 880, H = 240; + var pad = { left: 44, right: 16, top: 20, bottom: 32 }; + var chartW = W - pad.left - pad.right; + var chartH = H - pad.top - pad.bottom; - // Line - svg += ``; + var ratings = history.map(function(h) { return h.rating; }); + var minR = Math.min.apply(null, ratings) - 5; + var maxR = Math.max.apply(null, ratings) + 5; + var range = maxR - minR || 1; - // Data points - points.forEach((point, i) => { - svg += ``; - svg += ``; + function xOf(i) { + return pad.left + (i / Math.max(history.length - 1, 1)) * chartW; + } + function yOf(r) { + return pad.top + ((maxR - r) / range) * chartH; + } + + var pts = history.map(function(h, i) { + return { x: xOf(i), y: yOf(h.rating), rating: h.rating, date: h.date }; }); - // X-axis labels - 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}`; + var linePath = pts.map(function(p, i) { + return (i === 0 ? 'M' : 'L') + ' ' + p.x.toFixed(1) + ' ' + p.y.toFixed(1); + }).join(' '); + + var last = pts[pts.length - 1]; + var bottomY = (pad.top + chartH).toFixed(1); + var areaPath = linePath + + ' L ' + last.x.toFixed(1) + ' ' + bottomY + + ' L ' + pad.left.toFixed(1) + ' ' + bottomY + ' Z'; + + // Build SVG using DOM API to avoid innerHTML on user-supplied content + var ns = 'http://www.w3.org/2000/svg'; + + function el(tag, attrs) { + var e = document.createElementNS(ns, tag); + Object.keys(attrs).forEach(function(k) { e.setAttribute(k, attrs[k]); }); + return e; + } + + function txt(tag, attrs, text) { + var e = el(tag, attrs); + e.textContent = text; + return e; + } + + var svg = el('svg', { + viewBox: '0 0 ' + W + ' ' + H, + width: '100%', + style: 'display:block;overflow:visible', + 'aria-hidden': 'true' + }); + + // Grid lines + y-axis labels (4 ticks) + var tickCount = 4; + for (var i = 0; i <= tickCount; i++) { + var gy = pad.top + (i / tickCount) * chartH; + var gr = Math.round(maxR - (i / tickCount) * range); + svg.appendChild(el('line', { + x1: pad.left, y1: gy.toFixed(1), + x2: pad.left + chartW, y2: gy.toFixed(1), + stroke: 'var(--line)', 'stroke-width': '1', 'stroke-dasharray': '2 4' + })); + svg.appendChild(txt('text', { + x: pad.left - 8, y: (gy + 4).toFixed(1), + 'text-anchor': 'end', 'font-size': '10', + 'font-family': "'JetBrains Mono', monospace", + fill: 'var(--ink-3)' + }, String(gr))); + } + + // Area fill (8% opacity) + svg.appendChild(el('path', { + d: areaPath, fill: 'var(--accent)', 'fill-opacity': '0.08' + })); + + // Line + svg.appendChild(el('path', { + d: linePath, stroke: 'var(--accent)', 'stroke-width': '2', + fill: 'none', 'stroke-linejoin': 'round', 'stroke-linecap': 'round' + })); + + // Dots + pts.forEach(function(p, i) { + var isLast = i === pts.length - 1; + if (isLast) { + svg.appendChild(el('circle', { + cx: p.x.toFixed(1), cy: p.y.toFixed(1), + r: '4', fill: 'var(--accent)', stroke: 'white', 'stroke-width': '2' + })); + } else { + svg.appendChild(el('circle', { + cx: p.x.toFixed(1), cy: p.y.toFixed(1), + r: '3', fill: 'var(--accent)' + })); } }); - // Y-axis label - svg += `Rating`; + // X-axis labels (5 evenly spaced) + var labelCount = Math.min(5, history.length); + var labelIndices = []; + if (labelCount <= 1) { + labelIndices.push(0); + } else { + for (var k = 0; k < labelCount; k++) { + labelIndices.push(Math.round(k * (history.length - 1) / (labelCount - 1))); + } + } - svg += ''; + var seen = {}; + labelIndices.forEach(function(idx) { + if (seen[idx]) return; + seen[idx] = true; + var p = pts[idx]; + var d = new Date(history[idx].date); + var label = d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); + svg.appendChild(txt('text', { + x: p.x.toFixed(1), + y: (pad.top + chartH + 16).toFixed(1), + 'text-anchor': 'middle', 'font-size': '10', + 'font-family': "'JetBrains Mono', monospace", + fill: 'var(--ink-3)' + }, label)); + }); container.textContent = ''; - container.insertAdjacentHTML('beforeend', svg); - - setTimeout(() => { - const svgElement = document.getElementById(`svg-${pdgaNumber}`) || container.querySelector('svg'); - if (!svgElement) return; - - 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', '3.5'); - dataPoints[currentTooltip].setAttribute('fill', '#3b82f6'); - } - - currentTooltip = i; - const point = points[i]; - - tooltip.textContent = ''; - const strong = document.createElement('strong'); - strong.textContent = point.date; - tooltip.appendChild(strong); - tooltip.appendChild(document.createElement('br')); - tooltip.appendChild(document.createTextNode('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', '#2563eb'); - }); - - 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', '3.5'); - dataPoints[i].setAttribute('fill', '#3b82f6'); - currentTooltip = null; - }, 100); - } - }); - }); - }, 100); + container.appendChild(svg); } diff --git a/public/js/players.js b/public/js/players.js index 63a5806..e622de3 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -1,5 +1,6 @@ -let cachedDebugInfo = {}; -let pendingPlayerData = null; +var cachedDebugInfo = {}; +var pendingPlayerData = null; +var openPdgaNumber = null; function setupTooltipsAfterSwap() { document.body.addEventListener('htmx:afterSwap', function(event) { @@ -9,7 +10,7 @@ function setupTooltipsAfterSwap() { // 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'); + const container = target.querySelector('.player-chart, .chart-container'); if (container && container.dataset.history) { try { const history = JSON.parse(container.dataset.history); @@ -48,21 +49,46 @@ function initRatingsTooltips() { } function togglePlayerHistory(pdgaNumber) { - const historyRow = document.getElementById(`history-${pdgaNumber}`); - const contentDiv = document.getElementById(`history-content-${pdgaNumber}`); + var historyRow = document.getElementById('history-' + pdgaNumber); + var contentDiv = document.getElementById('history-content-' + pdgaNumber); + var expandableRow = document.getElementById('row-' + pdgaNumber); - if (historyRow.style.display === 'table-row') { + var isOpen = historyRow.style.display === 'table-row'; + + // Close any previously-open row + if (openPdgaNumber !== null && openPdgaNumber !== pdgaNumber) { + var prevHistory = document.getElementById('history-' + openPdgaNumber); + var prevRow = document.getElementById('row-' + openPdgaNumber); + if (prevHistory) { + prevHistory.style.display = 'none'; + prevHistory.classList.remove('is-open'); + } + if (prevRow) prevRow.classList.remove('row-open'); + openPdgaNumber = null; + } + + if (isOpen) { historyRow.style.display = 'none'; + historyRow.classList.remove('is-open'); + expandableRow.classList.remove('row-open'); + openPdgaNumber = null; return; } historyRow.style.display = 'table-row'; + // Force reflow so animation plays each open + historyRow.classList.remove('is-open'); + void historyRow.offsetWidth; + historyRow.classList.add('is-open'); + + expandableRow.classList.add('row-open'); + openPdgaNumber = pdgaNumber; if (contentDiv.dataset.loaded === 'true') { return; } - htmx.ajax('GET', `/partials/player-history/${pdgaNumber}`, {target: `#history-content-${pdgaNumber}`, swap: 'innerHTML'}); + htmx.ajax('GET', '/partials/player-history/' + pdgaNumber, {target: '#history-content-' + pdgaNumber, swap: 'innerHTML'}); contentDiv.dataset.loaded = 'true'; } @@ -497,3 +523,13 @@ document.addEventListener('DOMContentLoaded', function() { localStorage.setItem('ratingtracker.sparklines', next); }); }); + +// ── Expandable row keyboard support ─────────────── +document.addEventListener('keydown', function(e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + var row = e.target; + if (!row.classList || !row.classList.contains('expandable-row')) return; + e.preventDefault(); + var pdgaNumber = row.id.replace('row-', ''); + togglePlayerHistory(parseInt(pdgaNumber, 10)); +}); diff --git a/views/partials/player-history.ejs b/views/partials/player-history.ejs index 9bb0edf..ba627d1 100644 --- a/views/partials/player-history.ejs +++ b/views/partials/player-history.ejs @@ -1,12 +1,55 @@ -<% if (history && history.length > 0) { %> -
-
Loading chart...
+<% +var monthChange = (typeof player !== 'undefined' && player) ? player.ratingChange : null; +var monthPillText = monthChange != null ? (monthChange > 0 ? '+' + monthChange : monthChange.toString()) : null; +var monthPillClass = monthChange > 0 ? 'up' : monthChange < 0 ? 'down' : 'flat'; + +var gapPredicted = (typeof player !== 'undefined' && player) ? (player.deltaPredicted ?? null) : null; +var gapPillText = gapPredicted != null ? (gapPredicted > 0 ? '+' + gapPredicted : gapPredicted.toString()) : null; +var gapPillClass = gapPredicted > 0 ? 'up' : gapPredicted < 0 ? 'down' : 'flat'; + +var hasPlayer = (typeof player !== 'undefined' && player); +var chartPdgaNumber = hasPlayer ? player.pdgaNumber : pdgaNumber; +%> +
+ <% if (hasPlayer) { %> +
+
+
Current rating
+
<%= player.rating ?? '—' %>
-
-<% } else { %> -
-
No rating history available
+
+
Last month
+
<%= player.lastMonthRating ?? '—' %>
-<% } %> +
+
Change vs last month
+
+ <% if (monthPillText) { %> + <%= monthPillText %> + <% } else { %>—<% } %> +
+
+
+
Predicted next update
+
<%= player.predictedRating ?? '—' %>
+
+
+
Gap to predicted
+
+ <% if (gapPillText) { %> + <%= gapPillText %> + <% } else { %>—<% } %> +
+
+
+ <% } %> + + <% if (history && history.length > 0) { %> +
+
+ <% } else { %> +
No rating history available
+ <% } %> +
+
diff --git a/views/partials/ratings-table.ejs b/views/partials/ratings-table.ejs index 1795b94..d6bf061 100644 --- a/views/partials/ratings-table.ejs +++ b/views/partials/ratings-table.ejs @@ -53,7 +53,7 @@ function renderSparkline(values) { var sparklineSvg = renderSparkline(player.monthlyHistory || []); %> - + <%= index + 1 %> <%= player.name %>