diff --git a/app/bracket_view.py b/app/bracket_view.py index 726a8b1..09410bf 100644 --- a/app/bracket_view.py +++ b/app/bracket_view.py @@ -42,10 +42,23 @@ def build_bracket_view(year, bracket_payload, fetched_at=None): west_cf = [slot(ltr) for ltr in WEST_CF] cup = [slot(ltr) for ltr in CUP_FINAL] + all_rounds = [ + (1, east_r1 + west_r1), + (2, east_r2 + west_r2), + (3, east_cf + west_cf), + (4, cup), + ] + current_round = None + for r, matchups in all_rounds: + if any(m["state"] == "active" for m in matchups): + current_round = r + break + return { "year": year, "fetched_at": fetched_at, "bracket_logo": (bracket_payload or {}).get("bracketLogo"), + "current_round": current_round, "east_r1": east_r1, "west_r1": west_r1, "east_r2": east_r2, @@ -54,10 +67,10 @@ def build_bracket_view(year, bracket_payload, fetched_at=None): "west_cf": west_cf, "cup": cup, "rounds": [ - {"label": ROUND_LABELS[1], "east": east_r1, "west": west_r1}, - {"label": ROUND_LABELS[2], "east": east_r2, "west": west_r2}, - {"label": ROUND_LABELS[3], "east": east_cf, "west": west_cf}, - {"label": ROUND_LABELS[4], "cup": cup}, + {"label": ROUND_LABELS[1], "round_num": 1, "east": east_r1, "west": west_r1}, + {"label": ROUND_LABELS[2], "round_num": 2, "east": east_r2, "west": west_r2}, + {"label": ROUND_LABELS[3], "round_num": 3, "east": east_cf, "west": west_cf}, + {"label": ROUND_LABELS[4], "round_num": 4, "cup": cup}, ], } diff --git a/app/routes.py b/app/routes.py index 65fc4cf..c01d791 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,6 +1,8 @@ import json +import logging -from flask import abort, make_response, render_template, jsonify, send_from_directory +import requests as http_requests +from flask import abort, make_response, render_template, jsonify, request, send_from_directory from app import APP_VERSION, app, static_v from app.config import SCOREBOARD_DATA_FILE @@ -19,6 +21,18 @@ from datetime import datetime from zoneinfo import ZoneInfo _EASTERN = ZoneInfo("America/New_York") +_logger = logging.getLogger(__name__) + + +def _fetch_date(date_str): + url = f"https://api-web.nhle.com/v1/score/{date_str}" + try: + resp = http_requests.get(url, timeout=10) + resp.raise_for_status() + return resp.json() + except http_requests.RequestException as e: + _logger.error("Failed to fetch scores for %s: %s", date_str, e) + return None def _max_playoff_round(raw_games): @@ -69,15 +83,27 @@ def index(): @app.route("/scoreboard") def get_scoreboard(): - try: - with open(SCOREBOARD_DATA_FILE, "r") as json_file: - scoreboard_data = json.load(json_file) - except FileNotFoundError: - return jsonify({"error": "Failed to retrieve scoreboard data. File not found."}) - except json.JSONDecodeError: - return jsonify( - {"error": "Failed to retrieve scoreboard data. Invalid JSON format."} - ) + date_param = request.args.get("date") + today_str = datetime.now(_EASTERN).strftime("%Y-%m-%d") + + if date_param and date_param != today_str: + try: + datetime.strptime(date_param, "%Y-%m-%d") + except ValueError: + return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400 + scoreboard_data = _fetch_date(date_param) + if not scoreboard_data: + return jsonify({"error": "Failed to retrieve scoreboard data for that date."}) + else: + try: + with open(SCOREBOARD_DATA_FILE, "r") as json_file: + scoreboard_data = json.load(json_file) + except FileNotFoundError: + return jsonify({"error": "Failed to retrieve scoreboard data. File not found."}) + except json.JSONDecodeError: + return jsonify( + {"error": "Failed to retrieve scoreboard data. Invalid JSON format."} + ) if scoreboard_data: raw_games = scoreboard_data.get("games", []) diff --git a/app/series_view.py b/app/series_view.py index ed0a575..691d49c 100644 --- a/app/series_view.py +++ b/app/series_view.py @@ -77,6 +77,7 @@ def build_series_view(series_id, payload): "played_games": played, "next_game": next_game, "series_logo": payload.get("seriesLogo"), + "has_live": any(g["live"] for g in normalized_games), } diff --git a/app/static/script.js b/app/static/script.js index 98b445e..021f29c 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -1,13 +1,65 @@ +let failCount = 0; +const STALE_THRESHOLD = 3; + +// ── Date Navigation ────────────────────────────────── + +function localDateStr() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +let viewingDate = localDateStr(); + +function isToday() { + return viewingDate === localDateStr(); +} + +function shiftDate(offset) { + const [y, m, d] = viewingDate.split('-').map(Number); + const dt = new Date(y, m - 1, d + offset); + viewingDate = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; + updateDateLabel(); + startAutoRefresh(); +} + +function formatDateLabel(dateStr) { + if (dateStr === localDateStr()) return 'Today'; + const [y, m, d] = dateStr.split('-').map(Number); + const dt = new Date(y, m - 1, d); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + if (dt.toDateString() === yesterday.toDateString()) return 'Yesterday'; + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + if (dt.toDateString() === tomorrow.toDateString()) return 'Tomorrow'; + return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); +} + +function updateDateLabel() { + const label = document.getElementById('date-label'); + if (label) label.textContent = formatDateLabel(viewingDate); +} + async function fetchScoreboardData() { + const url = isToday() ? '/scoreboard' : `/scoreboard?date=${viewingDate}`; try { - const res = await fetch('/scoreboard'); + const res = await fetch(url); if (!res.ok) throw new Error(res.status); + failCount = 0; + setStale(false); updateScoreboard(await res.json()); } catch (e) { console.error('Failed to fetch scoreboard data:', e); + failCount++; + if (failCount >= STALE_THRESHOLD) setStale(true); } } +function setStale(stale) { + document.getElementById('stale-banner').classList.toggle('hidden', !stale); + document.querySelector('main').classList.toggle('stale', stale); +} + function updateScoreboard(data) { applyMeta(data.meta); @@ -34,6 +86,14 @@ function updateScoreboard(data) { if (hasGames) restoreClocks(grid, clockSnapshot); } + const anyGames = sections.some(s => s.games && s.games.length > 0); + document.getElementById('empty-state').classList.toggle('hidden', anyGames); + + restoreScroll(); + + const liveCount = (data.live_games || []).length + (data.intermission_games || []).length + (data.pinned_games || []).filter(g => g['Game State'] === 'LIVE').length; + document.title = liveCount ? `NHL Scoreboard (${liveCount} Live)` : 'NHL Scoreboard'; + updateGauges(); maybeNotifyOT(data); } @@ -372,10 +432,17 @@ function persistSeenOT(set) { function maybeNotifyOT(data) { if (!('Notification' in window)) return; + + const candidates = [...(data.pinned_games || []), ...(data.live_games || [])]; + const hasPlayoffOT = candidates.some(g => g['Playoff OT']); + + if (hasPlayoffOT && Notification.permission === 'default') { + Notification.requestPermission().catch(() => {}); + return; + } if (Notification.permission !== 'granted') return; const seen = seenOTKeys(); - const candidates = [...(data.pinned_games || []), ...(data.live_games || [])]; let changed = false; for (const g of candidates) { if (!g['Playoff OT']) continue; @@ -396,32 +463,68 @@ function maybeNotifyOT(data) { if (changed) persistSeenOT(seen); } -function requestNotificationPermission() { - if (!('Notification' in window)) return; - if (Notification.permission !== 'default') return; - Notification.requestPermission().catch(() => {}); +// ── Update Toast ───────────────────────────────────── + +function showUpdateToast() { + if (document.getElementById('update-toast')) return; + const toast = document.createElement('div'); + toast.id = 'update-toast'; + toast.className = 'update-toast'; + toast.innerHTML = 'New version available '; + toast.querySelector('button').addEventListener('click', () => location.reload()); + document.body.appendChild(toast); +} + +// ── Scroll Restoration ─────────────────────────────── + +const SCROLL_KEY = 'nhl_scroll_y'; +let scrollRestored = false; + +function saveScroll() { + sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); +} + +function restoreScroll() { + if (scrollRestored) return; + scrollRestored = true; + const y = parseInt(sessionStorage.getItem(SCROLL_KEY) || '0', 10); + if (y > 0) { + requestAnimationFrame(() => window.scrollTo(0, y)); + } + sessionStorage.removeItem(SCROLL_KEY); } // ── Init ───────────────────────────────────────────── -function autoRefresh() { +let refreshTimer = null; + +function startAutoRefresh() { + stopAutoRefresh(); fetchScoreboardData(); - setTimeout(autoRefresh, 5000); + if (isToday()) { + refreshTimer = setTimeout(startAutoRefresh, 5000); + } +} + +function stopAutoRefresh() { + if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } } window.addEventListener('load', () => { - requestNotificationPermission(); - autoRefresh(); + updateDateLabel(); + document.getElementById('date-prev').addEventListener('click', () => shiftDate(-1)); + document.getElementById('date-next').addEventListener('click', () => shiftDate(1)); + document.addEventListener('click', e => { + if (e.target.closest('.series-link')) saveScroll(); + }); + startAutoRefresh(); setInterval(tickClocks, 1000); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(err => { console.warn('Service worker registration failed:', err); }); - let reloading = false; navigator.serviceWorker.addEventListener('controllerchange', () => { - if (reloading) return; - reloading = true; - location.reload(); + showUpdateToast(); }); } }); diff --git a/app/static/styles.css b/app/static/styles.css index c03ca79..47a800e 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -54,6 +54,46 @@ header { color: var(--text); } +/* ── Date Navigation ───────────────────────────── */ + +.date-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding-bottom: 0.5rem; +} + +.date-btn { + background: var(--badge-bg); + border: 1px solid var(--card-border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 0.85rem; + font-weight: 600; + padding: 0.25rem 0.6rem; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.date-btn:hover { + background: #333; + border-color: #555; +} + +.date-btn:focus-visible { + outline: 2px solid var(--cup-gold-1); + outline-offset: 2px; +} + +.date-label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-muted); + min-width: 6rem; + text-align: center; +} + /* ── Layout ─────────────────────────────────────── */ main { @@ -71,6 +111,78 @@ main { display: none; } +.update-toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + background: var(--card); + border: 1px solid var(--card-border); + border-radius: var(--radius); + padding: 0.6rem 1rem; + font-size: 0.82rem; + color: var(--text); + display: flex; + align-items: center; + gap: 0.75rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + z-index: 1000; +} + +.update-toast-btn { + background: var(--cup-gold-1); + color: #1a1200; + border: none; + border-radius: var(--radius-sm); + padding: 0.3rem 0.7rem; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.update-toast-btn:hover { + background: var(--cup-gold-2); +} + +.stale-banner { + text-align: center; + padding: 0.45rem 1rem; + background: rgba(239, 68, 68, 0.12); + color: #fca5a5; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.03em; +} + +.stale-banner.hidden { + display: none; +} + +main.stale { + opacity: 0.6; +} + +.empty-state { + text-align: center; + padding: 4rem 1rem; +} + +.empty-state.hidden { + display: none; +} + +.empty-state-heading { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 0.4rem; +} + +.empty-state-sub { + font-size: 0.85rem; + color: #555; +} + .section-heading { font-size: 0.7rem; font-weight: 700; @@ -232,8 +344,10 @@ main { } .team-record { - font-size: 0.72rem; - color: var(--text-muted); + font-size: 0.78rem; + color: #999; + font-weight: 500; + font-variant-numeric: tabular-nums; margin-left: auto; flex-shrink: 0; white-space: nowrap; @@ -1020,7 +1134,13 @@ main { } .bracket-matchup-complete { - opacity: 0.75; + opacity: 0.55; +} + +.bracket-col-active { + color: var(--cup-gold-2); + border-bottom: 2px solid var(--cup-gold-1); + padding-bottom: 0.2rem; } .bracket-matchup-empty { diff --git a/app/templates/bracket.html b/app/templates/bracket.html index 4cf2386..b02a992 100644 --- a/app/templates/bracket.html +++ b/app/templates/bracket.html @@ -21,31 +21,31 @@ {# Desktop: 7-column grid (East R1 | R2 | CF | Cup | CF | R2 | R1 West) #}
-

First Round

+

First Round

{% for m in bracket.east_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
-

Second Round

+

Second Round

{% for m in bracket.east_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
-

East Final

+

East Final

{% for m in bracket.east_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
-

Cup Final

+

Cup Final

{% for m in bracket.cup %}{% include "_bracket_matchup.html" %}{% endfor %}
-

West Final

+

West Final

{% for m in bracket.west_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
-

Second Round

+

Second Round

{% for m in bracket.west_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
-

First Round

+

First Round

{% for m in bracket.west_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
@@ -53,7 +53,7 @@ {# Mobile: round-by-round accordion, round 1 open by default #}
{% for rnd in bracket.rounds %} -
+
{{ rnd.label }}
{% if rnd.get('east') %} diff --git a/app/templates/index.html b/app/templates/index.html index 861e2b9..b46b6b5 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -15,6 +15,11 @@
NHL Scoreboard +
+
+