+
No games scheduled today
+Check back tomorrow
+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 %}
+
{{ 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 @@