diff --git a/public/css/players.css b/public/css/players.css index 145c32e..7e862e3 100644 --- a/public/css/players.css +++ b/public/css/players.css @@ -2,46 +2,7 @@ Players Page ═══════════════════════════════════════════════════ */ -/* ── Add Player Button ─────────────────────────── */ - -.btn-add { - background: var(--green); -} - -.btn-add:hover { - background: #059669; -} - -/* ── Progress ─────────────────────────────────── */ - -.progress-container { - width: 100%; - background: var(--surface-3); - border-radius: var(--radius-xl); - padding: 3px; - margin: 20px 0; - overflow: hidden; -} - -.progress-bar { - width: 0%; - height: 26px; - background: linear-gradient(135deg, var(--accent), var(--accent-hover)); - border-radius: var(--radius-xl); - text-align: center; - line-height: 26px; - color: white; - font-weight: 600; - font-size: 12px; - transition: width 0.4s ease; -} - -.progress-text { - text-align: center; - margin: 8px 0; - font-size: 13px; - color: var(--text-secondary); -} +/* ── (Add player now uses .btn-primary from shared.css) ── */ /* ── Mobile helpers ───────────────────────────── */ @@ -61,12 +22,6 @@ /* ── Rating values ────────────────────────────── */ -.rating { - font-weight: 700; - color: var(--accent); - font-variant-numeric: tabular-nums; -} - .rating-value { font-variant-numeric: tabular-nums; } @@ -95,11 +50,6 @@ font-variant-numeric: tabular-nums; } -.refresh-section { - display: inline-flex; - align-items: center; -} - .predicted-value { position: relative; display: inline-block; diff --git a/public/css/shared.css b/public/css/shared.css index 2a02b57..155237a 100644 --- a/public/css/shared.css +++ b/public/css/shared.css @@ -2,53 +2,79 @@ PDGA Ratings — Shared Design System ═══════════════════════════════════════════════════ */ -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap'); +/* Fonts loaded via in layout.ejs for parallel loading */ :root { - /* Color system */ - --surface-0: #f0f2f5; - --surface-1: #ffffff; - --surface-2: #f8f9fb; - --surface-3: #eef0f4; + /* ── New design tokens ─────────────────────────── */ + --bg: oklch(0.985 0.005 250); + --paper: #ffffff; + --paper-2: oklch(0.975 0.006 250); + --ink: oklch(0.22 0.02 260); + --ink-2: oklch(0.42 0.015 260); + --ink-3: oklch(0.60 0.012 260); + --line: oklch(0.93 0.008 260); + --line-2: oklch(0.88 0.010 260); + --hover: oklch(0.965 0.012 260); - --navy-900: #0f172a; - --navy-800: #1e293b; - --navy-700: #334155; - --navy-600: #475569; + --accent: #4f5fd8; + --accent-soft: color-mix(in oklab, var(--accent) 12%, white); + --accent-text: color-mix(in oklab, var(--accent) 92%, black); - --text-primary: #0f172a; - --text-secondary: #64748b; - --text-muted: #94a3b8; - --text-inverse: #f8fafc; + --up: oklch(0.55 0.15 150); + --up-soft: oklch(0.94 0.04 150); + --down: oklch(0.58 0.18 25); + --down-soft: oklch(0.95 0.04 25); - --accent: #3b82f6; - --accent-hover: #2563eb; - --accent-subtle: rgba(59, 130, 246, 0.08); - --accent-border: rgba(59, 130, 246, 0.2); + --radius: 14px; + --radius-sm: 10px; - --green: #10b981; - --green-subtle: rgba(16, 185, 129, 0.1); - --red: #ef4444; - --red-subtle: rgba(239, 68, 68, 0.1); - --amber: #f59e0b; + --shadow-card: 0 1px 0 oklch(0.92 0.01 260), 0 1px 2px oklch(0.85 0.01 260 / 0.30); + --shadow-pop: 0 1px 2px oklch(0.80 0.01 260 / 0.18), 0 14px 40px -10px oklch(0.50 0.02 260 / 0.18); - --border: #e2e8f0; - --border-light: #f1f5f9; + --font-sans: 'Plus Jakarta Sans', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; + /* legacy token aliases — remove as components migrate */ + --surface-0: var(--bg); + --surface-1: var(--paper); + --surface-2: var(--paper-2); + --surface-3: var(--paper-2); + + --text-primary: var(--ink); + --text-secondary: var(--ink-2); + --text-muted: var(--ink-3); + --text-inverse: var(--paper); + + --navy-900: var(--ink); + --navy-800: var(--ink); + --navy-700: var(--ink-2); + --navy-600: var(--ink-2); + + --border: var(--line); + --border-light: var(--line-2); + + --green: var(--up); + --green-subtle: var(--up-soft); + --red: var(--down); + --red-subtle: var(--down-soft); + + --accent-hover: color-mix(in oklab, var(--accent) 85%, black); + --accent-subtle: var(--accent-soft); + --accent-border: color-mix(in oklab, var(--accent) 40%, transparent); + + --radius-md: var(--radius-sm); + --radius-lg: var(--radius); --radius-xl: 20px; - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); - --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.04); - --shadow-lg: 0 8px 30px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04); + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04); + --shadow-md: var(--shadow-card); + --shadow-lg: var(--shadow-pop); --shadow-overlay: 0 20px 60px rgba(15, 23, 42, 0.15), 0 4px 12px rgba(15, 23, 42, 0.08); - --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; - --font-mono: 'JetBrains Mono', 'SF Mono', monospace; - --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Topbar dimensions (used by sticky thead) */ + --topbar-height: 64px; } /* ── Reset & Base ─────────────────────────────── */ @@ -59,89 +85,220 @@ body { font-family: var(--font-sans); + font-size: 14px; + line-height: 1.45; + font-feature-settings: "ss01", "cv11"; margin: 0; padding: 0; - background-color: var(--surface-0); - color: var(--text-primary); + background: var(--bg); + color: var(--ink); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - line-height: 1.5; } -/* ── App Shell ────────────────────────────────── */ +.mono { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; +} -.app-header { - background: var(--navy-900); - color: var(--text-inverse); - padding: 0 24px; +/* ── Topbar ───────────────────────────────────── */ + +.topbar { position: sticky; top: 0; - z-index: 100; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + z-index: 50; + background: color-mix(in oklab, var(--paper) 92%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); } -.header-inner { - max-width: 960px; - margin: 0 auto; +.topbar__inner { display: flex; align-items: center; justify-content: space-between; - height: 56px; + gap: 16px; + padding: 14px 32px; + max-width: 1400px; + margin: 0 auto; } -.app-logo { - font-size: 17px; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--text-inverse); - text-decoration: none; +.topbar__brand { display: flex; align-items: center; - gap: 8px; + gap: 12px; + text-decoration: none; } -.app-logo .logo-icon { - width: 28px; - height: 28px; - background: var(--accent); - border-radius: var(--radius-sm); +.topbar__brand-mark { + width: 36px; + height: 36px; + border-radius: 10px; + background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 70%, black)); display: flex; align-items: center; justify-content: center; - font-size: 14px; + color: #fff; + flex-shrink: 0; } -.app-nav { +.topbar__brand-mark svg { + width: 24px; + height: 24px; +} + +.topbar__brand-text { display: flex; + flex-direction: column; + line-height: 1.1; +} + +.topbar__brand-title { + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); +} + +.topbar__brand-sub { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-3); +} + +.topbar__nav { + display: flex; + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 4px; gap: 2px; } -.app-nav a { - padding: 6px 14px; - color: var(--text-muted); +.topbar__nav a { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 14px; + border-radius: 7px; + font-size: 13px; + font-weight: 600; + color: var(--ink-2); text-decoration: none; - font-size: 14px; + transition: color 120ms ease, background 120ms ease; +} + +.topbar__nav a:hover { + color: var(--ink); +} + +.topbar__nav a.active { + background: var(--paper); + color: var(--ink); + box-shadow: var(--shadow-card); +} + +.topbar__meta { + display: flex; + align-items: center; + gap: 14px; +} + +.topbar__meta-item { + display: flex; + flex-direction: column; + line-height: 1.15; +} + +.topbar__meta-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-3); +} + +.topbar__meta-value { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + font-size: 12px; font-weight: 500; - border-radius: var(--radius-sm); - transition: color var(--transition), background var(--transition); + color: var(--ink); } -.app-nav a:hover { - color: var(--text-inverse); - background: rgba(255, 255, 255, 0.08); +.topbar__divider { + width: 1px; + height: 22px; + background: var(--line); } -.app-nav a.active { - color: var(--text-inverse); - background: rgba(255, 255, 255, 0.12); +.topbar__refresh { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--paper); + color: var(--ink); + font-size: 12px; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.topbar__refresh:hover:not(:disabled) { + background: var(--hover); + border-color: color-mix(in oklab, var(--line) 60%, var(--ink-3)); +} + +.topbar__refresh:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.topbar__refresh-spinner { + display: none; + width: 12px; + height: 12px; + border: 2px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: topbar-spin 0.7s linear infinite; +} + +.topbar__refresh.htmx-request .topbar__refresh-spinner { + display: inline-block; +} + +.topbar__refresh.htmx-request .topbar__refresh-icon { + display: none; +} + +@keyframes topbar-spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 880px) { + :root { --topbar-height: 56px; } + .topbar__inner { padding: 10px 16px; gap: 10px; } + .topbar__meta-item, .topbar__divider { display: none; } + .topbar__refresh-label { display: none; } + .topbar__nav a { padding: 0 10px; font-size: 12px; } + .topbar__brand-sub { display: none; } } /* ── Container ────────────────────────────────── */ .container { - max-width: 960px; + max-width: 1180px; margin: 0 auto; - padding: 28px 24px 60px; + padding: 28px 32px 60px; + display: flex; + flex-direction: column; + gap: 22px; } .page-title { @@ -149,16 +306,7 @@ body { font-weight: 700; color: var(--text-primary); letter-spacing: -0.03em; - margin: 0 0 24px 0; -} - -/* ── Loading ──────────────────────────────────── */ - -.loading { - text-align: center; - padding: 40px 20px; - font-size: 15px; - color: var(--text-secondary); + margin: 0; } /* ── Tables ───────────────────────────────────── */ @@ -170,35 +318,24 @@ table { font-size: 14px; } -thead { - position: sticky; - top: 56px; - z-index: 10; -} - th { - padding: 10px 16px; + padding: 0 16px; + height: 48px; text-align: left; font-weight: 600; font-size: 11px; text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-secondary); - background: var(--surface-2); - border-bottom: 1px solid var(--border); -} - -th:first-child { - border-radius: var(--radius-md) 0 0 0; -} - -th:last-child { - border-radius: 0 var(--radius-md) 0 0; + letter-spacing: 0.08em; + color: var(--ink-3); + background: var(--paper-2); + border-bottom: 1px solid var(--line); + vertical-align: middle; } td { - padding: 12px 16px; - border-bottom: 1px solid var(--border-light); + padding: 0 16px; + height: 64px; + border-bottom: 1px solid var(--line); vertical-align: middle; } @@ -206,27 +343,213 @@ tr:last-child td { border-bottom: none; } +/* Column widths */ +.col-rank { width: 56px; text-align: center; } +.col-player { min-width: 200px; } +.col-rating { min-width: 220px; } +.col-predicted { min-width: 180px; } +.col-actions { width: 72px; } + +/* Rank number */ +.rank-num { + font-size: 13px; + color: var(--ink-3); + font-weight: 500; +} + +/* Player name cell */ +.cell-name { + display: flex; + flex-direction: column; + gap: 2px; +} + +.player-name a { + font-weight: 600; + font-size: 14px; + letter-spacing: -0.005em; + color: var(--ink); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +.player-name a:hover { + color: var(--accent); +} + +.pdga-num { + font-size: 11px; + color: var(--ink-3); +} + +/* Rating cell stack */ +.cell-rating { + vertical-align: middle; +} + +.rating-stack { + display: flex; + align-items: center; + gap: 10px; +} + +.rating-big { + font-size: 20px; + font-weight: 600; + color: var(--ink); + letter-spacing: -0.02em; + line-height: 1; +} + +.rating-pending { + font-size: 12px; + font-style: italic; + color: var(--ink-3); +} + +/* Predicted cell stack */ +.pred-stack { + display: flex; + align-items: center; + gap: 10px; +} + +.pred-num { + font-size: 20px; + font-weight: 600; + color: var(--ink-2); + letter-spacing: -0.02em; + line-height: 1; +} + +/* Actions cell */ +.cell-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +/* Chevron rotation when row open */ +tr.row-open .icon-chev i { + transform: rotate(180deg); +} + +.icon-chev i { + transition: transform 180ms ease; +} + .expandable-row { cursor: pointer; - transition: background var(--transition); + transition: background 120ms ease; + outline: none; + position: relative; } .expandable-row:hover { - background: var(--accent-subtle); + background: var(--hover); } .expanded-content { 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); - border-top: 2px solid var(--accent); + background: color-mix(in oklab, var(--accent) 4%, var(--paper-2)); + border-bottom: 1px solid var(--line); } .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 ────────────────────────────────────── */ @@ -276,31 +599,129 @@ a:hover { box-shadow: none; } -/* ── Card Section ─────────────────────────────── */ +/* ── Add Player Bar ───────────────────────────── */ -.card-section { - background: var(--surface-1); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 24px; - margin-bottom: 28px; - box-shadow: var(--shadow-sm); -} - -.card-section h3 { - margin: 0 0 14px 0; - color: var(--text-primary); - font-size: 15px; - font-weight: 600; - text-align: center; -} - -.card-section-form { +.add-bar { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 20px 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + box-shadow: var(--shadow-card); +} + +.add-bar-label { + display: flex; + flex-direction: column; + gap: 2px; +} + +.add-bar-kicker { + font-weight: 700; + font-size: 16px; + letter-spacing: -0.01em; + color: var(--ink); +} + +.add-bar-hint { + font-size: 12px; + color: var(--ink-3); +} + +.add-bar-controls { display: flex; - justify-content: center; align-items: center; gap: 10px; - flex-wrap: wrap; + flex-shrink: 0; +} + +.input-wrap { + position: relative; + display: flex; + align-items: center; + background: var(--paper-2); + border: 1px solid var(--line-2); + border-radius: 10px; + height: 40px; + width: 220px; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.input-wrap:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 14%, transparent); +} + +.input-prefix { + padding: 0 0 0 14px; + color: var(--ink-3); + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + flex-shrink: 0; +} + +.input-wrap input { + flex: 1; + background: transparent; + border: none; + outline: none; + padding: 0 14px 0 6px; + height: 100%; + font-family: var(--font-mono); + font-size: 14px; + color: var(--ink); + font-feature-settings: "tnum"; +} + +.input-wrap input::placeholder { + color: var(--ink-3); + opacity: 0.7; +} + +.btn-primary { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--accent); + color: white; + border: none; + height: 40px; + padding: 0 18px; + border-radius: 10px; + font-weight: 600; + font-size: 13px; + font-family: var(--font-sans); + cursor: pointer; + box-shadow: 0 1px 0 color-mix(in oklab, var(--accent) 50%, black), 0 1px 3px color-mix(in oklab, var(--accent) 40%, transparent); + transition: transform 80ms ease, box-shadow 150ms ease, background 150ms ease; +} + +.btn-primary:hover { + background: color-mix(in oklab, var(--accent) 92%, black); +} + +.btn-primary:active { + transform: translateY(1px); +} + +@media (max-width: 768px) { + .add-bar { + flex-direction: column; + align-items: stretch; + } + + .add-bar-controls { + flex-wrap: wrap; + } + + .input-wrap { + flex: 1; + width: auto; + } } /* ── Inputs ───────────────────────────────────── */ @@ -326,20 +747,6 @@ a:hover { color: var(--text-muted); } -@media (max-width: 768px) { - .card-section { - padding: 16px; - } - - .card-section-form { - flex-direction: column; - } - - .card-section-form .input { - width: 100%; - } -} - /* ── Refresh Icons ────────────────────────────── */ .refresh-icon { @@ -371,26 +778,10 @@ a:hover { /* ── Responsive ───────────────────────────────── */ -@media (max-width: 768px) { +@media (max-width: 880px) { .container { - padding: 16px 12px 40px; - } - - .page-title { - font-size: 22px; - } - - .header-inner { - height: 48px; - } - - .app-logo { - font-size: 15px; - } - - .app-nav a { - padding: 5px 10px; - font-size: 13px; + padding: 18px 16px 40px; + gap: 16px; } table { @@ -398,24 +789,277 @@ a:hover { } th, td { - padding: 10px 8px; + padding: 0 10px; } - th { - font-size: 10px; - } + .col-rank { width: 40px; } + .col-actions { width: 40px; } + .col-predicted { display: none; } +} +@media (max-width: 768px) { .mobile-hide { display: none; } +} - thead { - top: 48px; +/* ── Delta Pills ──────────────────────────────── */ + +.delta-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px 3px 6px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 1; + white-space: nowrap; +} + +.delta-glyph { + font-size: 9px; + display: inline-block; + line-height: 1; + position: relative; + top: -0.5px; +} + +.delta-num { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + letter-spacing: 0; +} + +.delta-pill.up { + background: var(--up-soft); + color: var(--up); +} + +.delta-pill.down { + background: var(--down-soft); + color: var(--down); +} + +.delta-pill.flat { + background: var(--paper-2); + color: var(--ink-3); + border: 1px solid var(--line); +} + +/* ── Table Header Hints ───────────────────────── */ + +.th-hint { + display: block; + font-size: 9.5px; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: var(--ink-3); + margin-top: 2px; +} + +/* ── KPI Tiles ────────────────────────────────── */ + +.kpi-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + list-style: none; + padding: 0; + margin: 0; +} + +.kpi-tile { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px 16px; + display: flex; + gap: 12px; + box-shadow: var(--shadow-card); +} + +.kpi-rail { + width: 3px; + align-self: stretch; + border-radius: 2px; + background: var(--ink-3); + flex-shrink: 0; +} + +.kpi-rail.up { + background: var(--up); +} + +.kpi-rail.down { + background: var(--down); +} + +.kpi-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.kpi-value { + font-family: var(--font-mono); + font-feature-settings: "tnum", "zero"; + font-size: 28px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--ink); + line-height: 1; +} + +.kpi-label { + font-size: 13px; + font-weight: 500; + color: var(--ink); +} + +.kpi-sub { + font-size: 12px; + color: var(--ink-3); +} + +@media (max-width: 880px) { + .kpi-strip { + grid-template-columns: repeat(2, 1fr); } } -@media (max-width: 480px) { - th, td { - padding: 8px 6px; - } +/* ── Table Card + Toolbar ─────────────────────── */ + +.table-card { + background: var(--paper); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.kicker { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.10em; + color: var(--ink-3); +} + +.table-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + border-bottom: 1px solid var(--line); +} + +.toolbar-actions { + display: flex; + gap: 6px; + align-items: center; +} + +/* ── Icon Buttons ─────────────────────────────── */ + +.icon-btn { + background: transparent; + border: 1px solid transparent; + width: 28px; + height: 28px; + border-radius: 8px; + display: grid; + place-items: center; + color: var(--ink-3); + cursor: pointer; + font-size: 12px; + transition: background 150ms ease, color 150ms ease, border-color 150ms ease; +} + +.icon-btn:hover { + background: var(--hover); + color: var(--ink); + border-color: var(--line); +} + +/* ── Table Pill Toggle ────────────────────────── */ + +.pill-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px 5px 10px; + border-radius: 999px; + border: 1px solid var(--line-2); + background: var(--paper-2); + color: var(--ink-2); + font-size: 12px; + font-weight: 600; + font-family: var(--font-sans); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease; +} + +.pill-toggle:hover { + background: var(--hover); + border-color: var(--line); +} + +.pill-toggle[aria-pressed="true"] { + background: color-mix(in oklab, var(--accent) 10%, white); + border-color: color-mix(in oklab, var(--accent) 35%, var(--line-2)); + color: var(--accent-text); +} + +.pill-icon { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.pill-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ink-3); + opacity: 0.4; + transition: background 150ms ease, opacity 150ms ease, box-shadow 150ms ease; +} + +.pill-toggle[aria-pressed="true"] .pill-dot { + background: var(--accent); + opacity: 1; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 20%, transparent); +} + +/* ── Sparklines ───────────────────────────────── */ + +.sparkline { + display: block; + line-height: 0; + flex-shrink: 0; +} + +.spark { + display: block; + overflow: visible; +} + +body[data-sparklines="off"] .sparkline, +body[data-sparklines="off"] .spark { + display: none; +} + +/* ── Footnote ─────────────────────────────────── */ + +.footnote { + font-size: 11.5px; + color: var(--ink-3); + text-align: center; + max-width: 640px; + margin: 0 auto; + line-height: 1.6; + padding: 0 16px; } diff --git a/public/js/chart.js b/public/js/chart.js index abbf382..970d99a 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: 'var(--paper)', '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 d0c80e6..78503b4 100644 --- a/public/js/players.js +++ b/public/js/players.js @@ -1,5 +1,30 @@ -let cachedDebugInfo = {}; +const cachedDebugInfo = {}; let pendingPlayerData = null; +let openPdgaNumber = null; + +// ── Delta-pill helper ───────────────────────────── +function renderDeltaPill(value, extraClass) { + const isNull = (value == null); + const cls = isNull ? 'flat' : value > 0 ? 'up' : value < 0 ? 'down' : 'flat'; + const glyph = (isNull || value === 0) ? '–' : value > 0 ? '▲' : '▼'; + const num = isNull ? '—' : value > 0 ? '+' + value : String(value); + return { glyph, num, cls }; +} + +function applyDeltaPill(pillEl, value) { + if (!pillEl) return; + const pill = renderDeltaPill(value); + pillEl.className = 'delta-pill ' + pill.cls; + while (pillEl.firstChild) pillEl.removeChild(pillEl.firstChild); + const glyphSpan = document.createElement('span'); + glyphSpan.className = 'delta-glyph'; + glyphSpan.textContent = pill.glyph; + const numSpan = document.createElement('span'); + numSpan.className = 'delta-num'; + numSpan.textContent = pill.num; + pillEl.appendChild(glyphSpan); + pillEl.appendChild(numSpan); +} function setupTooltipsAfterSwap() { document.body.addEventListener('htmx:afterSwap', function(event) { @@ -9,7 +34,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 +73,46 @@ function initRatingsTooltips() { } function togglePlayerHistory(pdgaNumber) { - const historyRow = document.getElementById(`history-${pdgaNumber}`); - const contentDiv = document.getElementById(`history-content-${pdgaNumber}`); + const historyRow = document.getElementById('history-' + pdgaNumber); + const contentDiv = document.getElementById('history-content-' + pdgaNumber); + const expandableRow = document.getElementById('row-' + pdgaNumber); - if (historyRow.style.display === 'table-row') { + const isOpen = historyRow.style.display === 'table-row'; + + // Close any previously-open row + if (openPdgaNumber !== null && openPdgaNumber !== pdgaNumber) { + const prevHistory = document.getElementById('history-' + openPdgaNumber); + const prevRow = document.getElementById('row-' + openPdgaNumber); + if (prevHistory) { + prevHistory.style.display = 'none'; + prevHistory.classList.remove('is-open'); + } + if (prevRow) prevRow.classList.remove('row-open'); + openPdgaNumber = null; + } + + if (isOpen) { historyRow.style.display = 'none'; + historyRow.classList.remove('is-open'); + expandableRow.classList.remove('row-open'); + openPdgaNumber = null; return; } historyRow.style.display = 'table-row'; + // Force reflow so animation plays each open + historyRow.classList.remove('is-open'); + void historyRow.offsetWidth; + historyRow.classList.add('is-open'); + + expandableRow.classList.add('row-open'); + openPdgaNumber = pdgaNumber; if (contentDiv.dataset.loaded === 'true') { return; } - htmx.ajax('GET', `/partials/player-history/${pdgaNumber}`, {target: `#history-content-${pdgaNumber}`, swap: 'innerHTML'}); + htmx.ajax('GET', '/partials/player-history/' + pdgaNumber, {target: '#history-content-' + pdgaNumber, swap: 'innerHTML'}); contentDiv.dataset.loaded = 'true'; } @@ -85,28 +135,39 @@ async function clearCache() { } } -async function refreshPlayer(pdgaNumber) { - const icon = document.querySelector(`#row-${pdgaNumber} .rating .refresh-icon`); - icon.classList.add('spinning'); +// Refreshes both the current rating and the prediction in one click, then +// re-swaps the table so every derived value (deltas, pills, sparkline) reflects +// the new state. Cheaper than fine-grained DOM updates and guaranteed consistent +// because the server renders the truth. +async function refreshPlayerData(pdgaNumber) { + const icon = document.querySelector(`#row-${pdgaNumber} .cell-actions .refresh-icon`); + if (icon) icon.classList.add('spinning'); + try { + await Promise.allSettled([ + fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }), + fetch(`/api/refresh-round-history/${pdgaNumber}`, { method: 'POST' }) + ]); + htmx.ajax('GET', '/partials/ratings-table', { target: '#ratings-table', swap: 'innerHTML' }); + } catch (error) { + console.error('Error refreshing player data:', error); + } finally { + if (icon) icon.classList.remove('spinning'); + } +} +async function refreshPlayer(pdgaNumber) { try { const response = await fetch(`/api/refresh-player/${pdgaNumber}`, { method: 'POST' }); const data = await response.json(); if (data.success) { const row = document.getElementById(`row-${pdgaNumber}`); - const ratingCell = row.querySelector('.rating'); - const ratingChangeCell = row.querySelector('.rating-change'); + const ratingCell = row.querySelector('.cell-rating'); const nameLink = row.querySelector('.player-name a'); - nameLink.textContent = data.player.name; + if (nameLink) 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'); + const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null; if (ratingValue) { ratingValue.textContent = data.player.rating || 'N/A'; ratingValue.dataset.rating = data.player.rating || ''; @@ -122,27 +183,16 @@ async function refreshPlayer(pdgaNumber) { } } - 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}`; - } + const deltaMonthPill = ratingCell ? ratingCell.querySelector('.delta-pill') : null; + applyDeltaPill(deltaMonthPill, data.player.ratingChange); } } catch (error) { console.error('Error refreshing player:', error); alert('Failed to refresh player data'); - } 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(); @@ -171,8 +221,8 @@ async function refreshRoundHistory(pdgaNumber) { } const row = document.getElementById(`row-${pdgaNumber}`); - const ratingCell = row.querySelector('.rating'); - const ratingValue = ratingCell.querySelector('.rating-value'); + const ratingCell = row.querySelector('.cell-rating'); + const ratingValue = ratingCell ? ratingCell.querySelector('.rating-value') : null; if (ratingValue && data.stdDev) { ratingValue.dataset.stddev = data.stdDev; @@ -188,43 +238,16 @@ async function refreshRoundHistory(pdgaNumber) { } } } catch (error) { + // Rate-limited or scrape failure — common when refresh runs alongside the + // current-rating refresh. Log but don't alert; the user-facing surface is + // the spinner stopping (data may or may not have updated). console.error('Error refreshing round history:', error); - - 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'); + // No dedicated icon in the expanded row; spinner state not needed here + const icon = null; try { const response = await fetch(`/api/refresh-rating-history/${pdgaNumber}`, { method: 'POST' }); @@ -239,8 +262,6 @@ async function refreshRatingHistory(pdgaNumber) { } catch (error) { console.error('Error refreshing rating history:', error); alert('Failed to refresh rating history'); - } finally { - icon.classList.remove('spinning'); } } @@ -281,7 +302,8 @@ function closeDebugModal(event) { document.getElementById('debug-modal').style.display = 'none'; } -async function searchAndAddPlayer() { +async function searchAndAddPlayer(event) { + if (event) event.preventDefault(); const input = document.getElementById('pdga-number-input'); const pdgaNumber = input.value.trim(); @@ -290,10 +312,9 @@ async function searchAndAddPlayer() { return; } - const button = document.querySelector('.btn-add'); - const originalText = button.textContent; - button.disabled = true; - button.textContent = 'Searching...'; + const button = document.querySelector('.add-bar button[type="submit"]'); + const originalText = button ? button.textContent : ''; + if (button) { button.disabled = true; button.textContent = 'Searching...'; } try { const response = await fetch(`/api/search-player/${pdgaNumber}`); @@ -316,8 +337,7 @@ async function searchAndAddPlayer() { console.error('Error searching for player:', error); showErrorModal('Failed to search for player. Please try again.'); } finally { - button.disabled = false; - button.textContent = originalText; + if (button) { button.disabled = false; button.textContent = originalText; } } } @@ -486,3 +506,30 @@ function closeAddPlayerModal(event) { document.getElementById('add-player-modal').style.display = 'none'; pendingPlayerData = null; } + +// ── Sparkline toggle ─────────────────────────────── +document.addEventListener('DOMContentLoaded', function() { + const btn = document.getElementById('trendchart-toggle'); + if (!btn) return; + + const state = localStorage.getItem('ratingtracker.sparklines') || 'on'; + document.body.dataset.sparklines = state; + btn.setAttribute('aria-pressed', state === 'on' ? 'true' : 'false'); + + btn.addEventListener('click', function() { + const next = document.body.dataset.sparklines === 'on' ? 'off' : 'on'; + document.body.dataset.sparklines = next; + btn.setAttribute('aria-pressed', next === 'on' ? 'true' : 'false'); + localStorage.setItem('ratingtracker.sparklines', next); + }); +}); + +// ── Expandable row keyboard support ─────────────── +document.addEventListener('keydown', function(e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + const row = e.target; + if (!row.classList || !row.classList.contains('expandable-row')) return; + e.preventDefault(); + const pdgaNumber = row.id.replace('row-', ''); + togglePlayerHistory(parseInt(pdgaNumber, 10)); +}); diff --git a/public/js/progress.js b/public/js/progress.js deleted file mode 100644 index 4f9b18a..0000000 --- a/public/js/progress.js +++ /dev/null @@ -1,97 +0,0 @@ -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'; - htmx.ajax('GET', '/partials/ratings-table', '#ratings-table'); - eventSource.close(); - } - }; - - eventSource.onerror = function() { - progressSection.style.display = 'none'; - tableDiv.textContent = '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.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') { - 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'; - 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.textContent = '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/server.js b/server.js index 7747255..db7d620 100644 --- a/server.js +++ b/server.js @@ -14,6 +14,7 @@ app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views/pages')); app.use(express.static('public')); app.use(express.json()); +app.use(express.urlencoded({ extended: false })); app.use(playerRoutes); app.use(courseRoutes); diff --git a/src/models/player.js b/src/models/player.js index 856122c..5460378 100644 --- a/src/models/player.js +++ b/src/models/player.js @@ -33,7 +33,7 @@ function getRatingHistoryFromDB(pdgaNumber) { db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => { if (err) return reject(err); if (!player) return resolve(null); - + db.all( 'SELECT * FROM rating_history WHERE player_id = ? ORDER BY date ASC', [player.id], @@ -51,26 +51,26 @@ function saveRatingHistoryToDB(pdgaNumber, ratingHistory) { db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => { if (err) return reject(err); if (!player) return reject(new Error('Player not found')); - + db.run('DELETE FROM rating_history WHERE player_id = ?', [player.id], (err) => { if (err) return reject(err); - + if (ratingHistory.length === 0) { return resolve(); } - + let completed = 0; const total = ratingHistory.length; - + ratingHistory.forEach(entry => { const parsedDate = parseDate(entry.date); - + db.run( 'INSERT INTO rating_history (player_id, date, rating) VALUES (?, ?, ?)', [player.id, parsedDate.toISOString().split('T')[0], entry.rating], (err) => { if (err) return reject(err); - + completed++; if (completed === total) { resolve(); @@ -88,7 +88,7 @@ function getRoundHistoryFromDB(pdgaNumber) { db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => { if (err) return reject(err); if (!player) return resolve([]); - + db.all( 'SELECT * FROM round_history WHERE player_id = ? ORDER BY date DESC', [player.id], @@ -132,7 +132,7 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) { db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => { if (err) return reject(err); if (!player) return reject(new Error('Player not found')); - + const processRounds = () => { if (roundData.length === 0) { db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (err) => { @@ -141,13 +141,13 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) { }); return; } - + const stmt = db.prepare('INSERT OR REPLACE INTO round_history (player_id, date, competition_name, rating) VALUES (?, ?, ?, ?)'); - + for (const round of roundData) { stmt.run([player.id, round.date.toISOString().split('T')[0], round.competition || 'Unknown', round.rating]); } - + stmt.finalize((err) => { if (err) { reject(err); @@ -159,7 +159,7 @@ function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) { } }); }; - + if (!isIncremental) { db.run('DELETE FROM round_history WHERE player_id = ?', [player.id], (err) => { if (err) return reject(err); @@ -185,6 +185,87 @@ function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) { }); } +/** + * Returns monthly rating snapshots for one player (latest entry per calendar month), + * ordered oldest → newest. At most `months` entries; [] if none. + */ +function getMonthlyHistory(pdgaNumber, months = 12) { + return new Promise((resolve, reject) => { + db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => { + if (err) return reject(err); + if (!player) return resolve([]); + + db.all( + `SELECT rating + FROM rating_history + WHERE player_id = ? + AND date IN ( + SELECT MAX(date) + FROM rating_history + WHERE player_id = ? + GROUP BY strftime('%Y-%m', date) + ) + ORDER BY date DESC + LIMIT ?`, + [player.id, player.id, months], + (err, rows) => { + if (err) return reject(err); + resolve(rows.map(r => r.rating).reverse()); + } + ); + }); + }); +} + +/** + * Fetches the last `months` monthly rating snapshots for ALL players in one query. + * Returns a Map (oldest → newest per player). + * Use this in bulk-fetch paths to avoid N+1 queries. + */ +function getAllMonthlyHistoriesFromDB(months = 12) { + return new Promise((resolve, reject) => { + db.all( + `SELECT p.pdga_number, rh.date, rh.rating + FROM rating_history rh + JOIN players p ON rh.player_id = p.id + INNER JOIN ( + SELECT player_id, MAX(date) AS max_date + FROM rating_history + GROUP BY player_id, strftime('%Y-%m', date) + ) latest ON rh.player_id = latest.player_id AND rh.date = latest.max_date + ORDER BY p.pdga_number, rh.date ASC`, + [], + (err, rows) => { + if (err) return reject(err); + + const map = new Map(); + for (const row of rows) { + if (!map.has(row.pdga_number)) map.set(row.pdga_number, []); + map.get(row.pdga_number).push(row.rating); + } + // Trim each player's history to the requested window + for (const [key, arr] of map) { + if (arr.length > months) map.set(key, arr.slice(-months)); + } + resolve(map); + } + ); + }); +} + +function getLastRefresh() { + return new Promise((resolve, reject) => { + db.get( + 'SELECT MAX(last_updated) AS lastRefresh FROM players', + [], + (err, row) => { + if (err) reject(err); + else resolve(row ? row.lastRefresh : null); + } + ); + }); +} + module.exports = { getPlayerFromDB, savePlayerToDB, @@ -194,5 +275,8 @@ module.exports = { getLastRoundUpdateDate, updateLastRoundUpdateDate, saveRoundHistoryToDB, - savePredictedRatingToDB + savePredictedRatingToDB, + getLastRefresh, + getMonthlyHistory, + getAllMonthlyHistoriesFromDB }; diff --git a/src/routes/pages.js b/src/routes/pages.js index 2eb677f..61d3c8d 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -1,12 +1,18 @@ const express = require('express'); const router = express.Router(); +const { getTopbarLocals } = require('../services/topbar-service'); +const { getAllRatingsFromDB, computeKpis } = require('../services/player-service'); -router.get('/', (req, res) => { - res.render('index'); +router.get('/', async (req, res) => { + const topbar = await getTopbarLocals(); + const players = await getAllRatingsFromDB(); + const kpis = computeKpis(players); + res.render('index', { activePage: 'players', kpis, ...topbar }); }); -router.get('/courses', (req, res) => { - res.render('courses'); +router.get('/courses', async (req, res) => { + const topbar = await getTopbarLocals(); + res.render('courses', { activePage: 'courses', ...topbar }); }); // Keep old URL working diff --git a/src/routes/players.js b/src/routes/players.js index 1a44d0d..4d7ecc4 100644 --- a/src/routes/players.js +++ b/src/routes/players.js @@ -6,9 +6,34 @@ const { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHis const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer'); const { launchBrowser } = require('../scrapers/browser'); const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service'); +const { getTopbarLocals } = require('../services/topbar-service'); const { calculatePredictedRating } = require('../services/rating-calculator'); const logger = require('../logger'); +let refreshInProgress = false; + +router.post('/api/refresh-all', async (req, res, next) => { + if (refreshInProgress) { + logger.info('refresh-all already in progress, rejecting'); + return res.status(409).json({ error: 'Refresh already in progress' }); + } + refreshInProgress = true; + try { + try { + await refreshAllPlayersInDB(); + } catch (err) { + logger.error({ err }, 'refresh-all failed'); + } + const page = req.body?.page === 'courses' ? 'courses' : 'players'; + const locals = await getTopbarLocals(); + res.render('../partials/topbar', { activePage: page, ...locals }); + } catch (err) { + next(err); + } finally { + refreshInProgress = false; + } +}); + router.get('/partials/ratings-table', async (req, res) => { try { const ratings = await getAllRatingsFromDB(); @@ -39,7 +64,8 @@ router.get('/partials/player-history/:pdgaNumber', async (req, res) => { displayDate: new Date(row.date).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' }) })); - res.render('../partials/player-history', { pdgaNumber, history: formattedHistory }); + const player = await getPlayerDataFromDB(pdgaNumber); + res.render('../partials/player-history', { pdgaNumber, history: formattedHistory, player }); } catch (error) { logger.error('Error loading player history:', error.message); res.status(500).send('
Error loading rating history
'); @@ -81,75 +107,6 @@ router.get('/api/ratings/progress', (req, res) => { }); }); -router.post('/api/populate-database', (req, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - }); - - const progressCallback = (progress) => { - res.write(`data: ${JSON.stringify(progress)}\n\n`); - }; - - logger.info('=== Starting database population from database players ==='); - - refreshAllPlayersInDB(progressCallback).then(ratings => { - logger.info(`=== Database population complete: ${ratings.length} players refreshed ===`); - res.write(`data: ${JSON.stringify({ status: 'complete', ratings, message: `Successfully refreshed ${ratings.length} players` })}\n\n`); - res.end(); - }).catch(error => { - logger.error('Error populating database:', error); - res.write(`data: ${JSON.stringify({ status: 'error', message: error.message })}\n\n`); - res.end(); - }); -}); - -router.get('/api/database-status', async (req, res) => { - try { - const playerCount = await new Promise((resolve, reject) => { - db.get('SELECT COUNT(*) as count FROM players', [], (err, row) => { - if (err) reject(err); - else resolve(row.count); - }); - }); - - res.json({ - playersInDB: playerCount, - needsPopulation: playerCount === 0 - }); - } catch (error) { - res.status(500).json({ error: 'Failed to check database status' }); - } -}); - -router.get('/api/load-all-players', (req, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control' - }); - - const progressCallback = (progress) => { - res.write(`data: ${JSON.stringify(progress)}\n\n`); - }; - - refreshAllPlayersInDB(progressCallback).then(ratings => { - res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`); - res.end(); - }).catch(error => { - res.write(`data: ${JSON.stringify({ status: 'error', error: error.message })}\n\n`); - res.end(); - }); - - req.on('close', () => { - res.end(); - }); -}); - router.get('/api/rating-history/:pdgaNumber', async (req, res) => { try { const { pdgaNumber } = req.params; diff --git a/src/services/player-service.js b/src/services/player-service.js index 79f7157..eb5e4dd 100644 --- a/src/services/player-service.js +++ b/src/services/player-service.js @@ -1,10 +1,32 @@ const { db } = require('../db'); -const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB } = require('../models/player'); +const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB, getMonthlyHistory, getAllMonthlyHistoriesFromDB } = require('../models/player'); const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http'); const { calculatePredictedRating } = require('./rating-calculator'); const logger = require('../logger'); -async function getPlayerDataFromDB(pdgaNumber) { +// Derives previous-month rating and the delta to it. Prefers PDGA's reported +// rating_change (canonical), falls back to our own monthly snapshots when +// rating_change is missing — common for players whose latest scrape failed. +function deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory) { + if (rating != null && rawRatingChange != null) { + return { lastMonthRating: rating - rawRatingChange, ratingChange: rawRatingChange }; + } + if (rating != null && monthlyHistory && monthlyHistory.length >= 1) { + // The "last month" snapshot depends on whether current_rating is already in + // history. If equal, current is the most recent entry — last month is the one + // before it. If not, current is newer than history — the latest entry IS last month. + const lastIdx = monthlyHistory.length - 1; + const lastMonth = (monthlyHistory[lastIdx] === rating) + ? (monthlyHistory.length >= 2 ? monthlyHistory[lastIdx - 1] : null) + : monthlyHistory[lastIdx]; + if (lastMonth != null) { + return { lastMonthRating: lastMonth, ratingChange: rating - lastMonth }; + } + } + return { lastMonthRating: null, ratingChange: rawRatingChange }; +} + +async function getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory = true } = {}) { try { const cachedPlayer = await getPlayerFromDB(pdgaNumber); if (cachedPlayer) { @@ -18,13 +40,29 @@ async function getPlayerDataFromDB(pdgaNumber) { stdDev = updatedPlayer?.std_dev; } + const rating = cachedPlayer.current_rating; + const rawRatingChange = cachedPlayer.rating_change; + const resolvedPredicted = predictedRating > 0 ? predictedRating : null; + const resolvedStdDev = stdDev > 0 ? stdDev : null; + + // Skip in bulk-fetch paths where caller supplies history via getAllMonthlyHistoriesFromDB + const monthlyHistory = includeMonthlyHistory + ? await getMonthlyHistory(cachedPlayer.pdga_number) + : []; + + const { lastMonthRating, ratingChange } = deriveMonthlyDeltas(rating, rawRatingChange, monthlyHistory); + return { pdgaNumber: cachedPlayer.pdga_number, name: cachedPlayer.name, - rating: cachedPlayer.current_rating, - ratingChange: cachedPlayer.rating_change, - predictedRating: predictedRating > 0 ? predictedRating : null, - stdDev: stdDev > 0 ? stdDev : null + rating, + ratingChange, + predictedRating: resolvedPredicted, + stdDev: resolvedStdDev, + lastMonthRating, + // gap between next predicted update and current rating (null when either is missing) + deltaPredicted: (resolvedPredicted != null && rating != null) ? resolvedPredicted - rating : null, + monthlyHistory }; } return null; @@ -127,6 +165,9 @@ async function getAllRatingsFromDB(progressCallback = null) { logger.info(`Loading ${allPlayers.length} players from database...`); + // Fetch all monthly histories in one query so the per-player loop doesn't add N extra queries + const monthlyHistoryMap = await getAllMonthlyHistoriesFromDB(12); + const ratings = []; const total = allPlayers.length; @@ -144,9 +185,14 @@ async function getAllRatingsFromDB(progressCallback = null) { } try { - const playerData = await getPlayerDataFromDB(pdgaNumber); + const playerData = await getPlayerDataFromDB(pdgaNumber, { includeMonthlyHistory: false }); if (playerData) { + playerData.monthlyHistory = monthlyHistoryMap.get(pdgaNumber) ?? []; + // Re-derive now that history is attached — bulk path skipped includeMonthlyHistory + const derived = deriveMonthlyDeltas(playerData.rating, player.rating_change, playerData.monthlyHistory); + playerData.lastMonthRating = derived.lastMonthRating; + playerData.ratingChange = derived.ratingChange; ratings.push(playerData); } @@ -161,12 +207,18 @@ async function getAllRatingsFromDB(progressCallback = null) { } } catch (error) { logger.error(`Failed to load PDGA ${pdgaNumber} from database:`, error.message); + const errorRating = player.current_rating; + const errorRatingChange = player.rating_change; const errorData = { pdgaNumber: parseInt(pdgaNumber), name: player.name || 'Database Error', - rating: player.current_rating, - ratingChange: player.rating_change, - predictedRating: null + rating: errorRating, + ratingChange: errorRatingChange, + predictedRating: null, + stdDev: null, + lastMonthRating: (errorRating != null && errorRatingChange != null) ? errorRating - errorRatingChange : null, + deltaPredicted: null, + monthlyHistory: [] }; ratings.push(errorData); @@ -267,10 +319,30 @@ async function refreshAllPlayersInDB(progressCallback = null) { } } +/** + * Aggregates KPI summary stats from an already-fetched player array. + * All fields are derived from the player list — no extra DB queries. + */ +function computeKpis(players) { + const active = players.filter(p => p.rating != null && p.rating > 0); + const avg = active.length > 0 + ? Math.round(active.reduce((sum, p) => sum + p.rating, 0) / active.length) + : null; + + return { + tracked: players.length, + active: active.length, + avg, + climbing: players.filter(p => p.ratingChange != null && p.ratingChange > 0).length, + slipping: players.filter(p => p.ratingChange != null && p.ratingChange < 0).length + }; +} + module.exports = { getPlayerDataFromDB, scrapePDGARating, getPredictedRatingFromDB, getAllRatingsFromDB, - refreshAllPlayersInDB + refreshAllPlayersInDB, + computeKpis }; diff --git a/src/services/topbar-service.js b/src/services/topbar-service.js new file mode 100644 index 0000000..e050667 --- /dev/null +++ b/src/services/topbar-service.js @@ -0,0 +1,43 @@ +const { getLastRefresh } = require('../models/player'); +const logger = require('../logger'); + +function formatRelative(isoString) { + if (!isoString) return 'Never'; + const then = new Date(isoString.replace(' ', 'T') + (isoString.endsWith('Z') ? '' : 'Z')); + const diffMs = Date.now() - then.getTime(); + if (Number.isNaN(diffMs) || diffMs < 0) return 'Just now'; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return 'Just now'; + const min = Math.floor(sec / 60); + if (min < 60) return `${min} min ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr} h ago`; + const day = Math.floor(hr / 24); + if (day === 1) return 'Yesterday'; + if (day < 7) return `${day} days ago`; + return then.toISOString().slice(0, 10); +} + +// First Tuesday of next month — approximation of PDGA's monthly cycle +function computeNextUpdate(now = new Date()) { + const year = now.getUTCFullYear(); + const month = now.getUTCMonth() + 1; // next month, may roll over + const candidate = new Date(Date.UTC(month === 12 ? year + 1 : year, month === 12 ? 0 : month, 1)); + // 0=Sun, 1=Mon, 2=Tue + const offset = (2 - candidate.getUTCDay() + 7) % 7; + candidate.setUTCDate(1 + offset); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][candidate.getUTCDay()]} ${candidate.getUTCDate()} ${months[candidate.getUTCMonth()]}`; +} + +async function getTopbarLocals() { + try { + const lastIso = await getLastRefresh(); + return { lastRefresh: formatRelative(lastIso), nextUpdate: computeNextUpdate() }; + } catch (err) { + logger.warn({ err }, 'topbar locals fallback'); + return { lastRefresh: 'Unknown', nextUpdate: computeNextUpdate() }; + } +} + +module.exports = { getTopbarLocals }; diff --git a/views/pages/courses.ejs b/views/pages/courses.ejs index 0fe9cc3..63f3a99 100644 --- a/views/pages/courses.ejs +++ b/views/pages/courses.ejs @@ -19,13 +19,11 @@ -
`; %> <%- include('../partials/layout', { title: 'PDGA Courses - Sweden', - heading: 'PDGA Courses - Sweden', activePage: 'courses', cssFiles: ['courses.css'], jsFiles: ['courses.js'], diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 9b1ab80..c600e18 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -1,33 +1,83 @@ <% var body = ` - -
-

Add Yourself to Tracked Players

-
- -
-
-
- Load All - -
- -