feat: add 10 UX improvements from interface review
CI / Lint (push) Failing after 10s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped

- Stale data banner after 3 consecutive fetch failures, auto-clears on recovery
- Date navigation with left/right arrows (Yesterday/Today/Tomorrow labels),
  fetches from NHL API for non-today dates, disables auto-refresh on history
- Empty state message when no games are scheduled
- Series detail page auto-refreshes every 30s when a game is live
- Notification permission deferred until a playoff OT actually occurs
- Scroll position saved/restored when navigating to/from series detail
- Team records rendered with better contrast and tabular nums
- Active bracket round highlighted with gold heading + underline,
  completed rounds dimmed more aggressively, mobile accordion auto-opens
  current round
- Browser tab title shows live game count (e.g. "NHL Scoreboard (3 Live)")
- Service worker update shows a dismissable toast instead of force-reloading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 20:22:03 -04:00
parent 58b27ddd20
commit 2da60e27ae
8 changed files with 313 additions and 39 deletions
+17 -4
View File
@@ -42,10 +42,23 @@ def build_bracket_view(year, bracket_payload, fetched_at=None):
west_cf = [slot(ltr) for ltr in WEST_CF] west_cf = [slot(ltr) for ltr in WEST_CF]
cup = [slot(ltr) for ltr in CUP_FINAL] 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 { return {
"year": year, "year": year,
"fetched_at": fetched_at, "fetched_at": fetched_at,
"bracket_logo": (bracket_payload or {}).get("bracketLogo"), "bracket_logo": (bracket_payload or {}).get("bracketLogo"),
"current_round": current_round,
"east_r1": east_r1, "east_r1": east_r1,
"west_r1": west_r1, "west_r1": west_r1,
"east_r2": east_r2, "east_r2": east_r2,
@@ -54,10 +67,10 @@ def build_bracket_view(year, bracket_payload, fetched_at=None):
"west_cf": west_cf, "west_cf": west_cf,
"cup": cup, "cup": cup,
"rounds": [ "rounds": [
{"label": ROUND_LABELS[1], "east": east_r1, "west": west_r1}, {"label": ROUND_LABELS[1], "round_num": 1, "east": east_r1, "west": west_r1},
{"label": ROUND_LABELS[2], "east": east_r2, "west": west_r2}, {"label": ROUND_LABELS[2], "round_num": 2, "east": east_r2, "west": west_r2},
{"label": ROUND_LABELS[3], "east": east_cf, "west": west_cf}, {"label": ROUND_LABELS[3], "round_num": 3, "east": east_cf, "west": west_cf},
{"label": ROUND_LABELS[4], "cup": cup}, {"label": ROUND_LABELS[4], "round_num": 4, "cup": cup},
], ],
} }
+36 -10
View File
@@ -1,6 +1,8 @@
import json 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 import APP_VERSION, app, static_v
from app.config import SCOREBOARD_DATA_FILE from app.config import SCOREBOARD_DATA_FILE
@@ -19,6 +21,18 @@ from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
_EASTERN = ZoneInfo("America/New_York") _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): def _max_playoff_round(raw_games):
@@ -69,15 +83,27 @@ def index():
@app.route("/scoreboard") @app.route("/scoreboard")
def get_scoreboard(): def get_scoreboard():
try: date_param = request.args.get("date")
with open(SCOREBOARD_DATA_FILE, "r") as json_file: today_str = datetime.now(_EASTERN).strftime("%Y-%m-%d")
scoreboard_data = json.load(json_file)
except FileNotFoundError: if date_param and date_param != today_str:
return jsonify({"error": "Failed to retrieve scoreboard data. File not found."}) try:
except json.JSONDecodeError: datetime.strptime(date_param, "%Y-%m-%d")
return jsonify( except ValueError:
{"error": "Failed to retrieve scoreboard data. Invalid JSON format."} 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: if scoreboard_data:
raw_games = scoreboard_data.get("games", []) raw_games = scoreboard_data.get("games", [])
+1
View File
@@ -77,6 +77,7 @@ def build_series_view(series_id, payload):
"played_games": played, "played_games": played,
"next_game": next_game, "next_game": next_game,
"series_logo": payload.get("seriesLogo"), "series_logo": payload.get("seriesLogo"),
"has_live": any(g["live"] for g in normalized_games),
} }
+117 -14
View File
@@ -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() { async function fetchScoreboardData() {
const url = isToday() ? '/scoreboard' : `/scoreboard?date=${viewingDate}`;
try { try {
const res = await fetch('/scoreboard'); const res = await fetch(url);
if (!res.ok) throw new Error(res.status); if (!res.ok) throw new Error(res.status);
failCount = 0;
setStale(false);
updateScoreboard(await res.json()); updateScoreboard(await res.json());
} catch (e) { } catch (e) {
console.error('Failed to fetch scoreboard data:', 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) { function updateScoreboard(data) {
applyMeta(data.meta); applyMeta(data.meta);
@@ -34,6 +86,14 @@ function updateScoreboard(data) {
if (hasGames) restoreClocks(grid, clockSnapshot); 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(); updateGauges();
maybeNotifyOT(data); maybeNotifyOT(data);
} }
@@ -372,10 +432,17 @@ function persistSeenOT(set) {
function maybeNotifyOT(data) { function maybeNotifyOT(data) {
if (!('Notification' in window)) return; 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; if (Notification.permission !== 'granted') return;
const seen = seenOTKeys(); const seen = seenOTKeys();
const candidates = [...(data.pinned_games || []), ...(data.live_games || [])];
let changed = false; let changed = false;
for (const g of candidates) { for (const g of candidates) {
if (!g['Playoff OT']) continue; if (!g['Playoff OT']) continue;
@@ -396,32 +463,68 @@ function maybeNotifyOT(data) {
if (changed) persistSeenOT(seen); if (changed) persistSeenOT(seen);
} }
function requestNotificationPermission() { // ── Update Toast ─────────────────────────────────────
if (!('Notification' in window)) return;
if (Notification.permission !== 'default') return; function showUpdateToast() {
Notification.requestPermission().catch(() => {}); if (document.getElementById('update-toast')) return;
const toast = document.createElement('div');
toast.id = 'update-toast';
toast.className = 'update-toast';
toast.innerHTML = 'New version available <button class="update-toast-btn">Reload</button>';
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 ───────────────────────────────────────────── // ── Init ─────────────────────────────────────────────
function autoRefresh() { let refreshTimer = null;
function startAutoRefresh() {
stopAutoRefresh();
fetchScoreboardData(); fetchScoreboardData();
setTimeout(autoRefresh, 5000); if (isToday()) {
refreshTimer = setTimeout(startAutoRefresh, 5000);
}
}
function stopAutoRefresh() {
if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; }
} }
window.addEventListener('load', () => { window.addEventListener('load', () => {
requestNotificationPermission(); updateDateLabel();
autoRefresh(); 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); setInterval(tickClocks, 1000);
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(err => { navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('Service worker registration failed:', err); console.warn('Service worker registration failed:', err);
}); });
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
if (reloading) return; showUpdateToast();
reloading = true;
location.reload();
}); });
} }
}); });
+123 -3
View File
@@ -54,6 +54,46 @@ header {
color: var(--text); 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 ─────────────────────────────────────── */ /* ── Layout ─────────────────────────────────────── */
main { main {
@@ -71,6 +111,78 @@ main {
display: none; 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 { .section-heading {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
@@ -232,8 +344,10 @@ main {
} }
.team-record { .team-record {
font-size: 0.72rem; font-size: 0.78rem;
color: var(--text-muted); color: #999;
font-weight: 500;
font-variant-numeric: tabular-nums;
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
white-space: nowrap; white-space: nowrap;
@@ -1020,7 +1134,13 @@ main {
} }
.bracket-matchup-complete { .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 { .bracket-matchup-empty {
+8 -8
View File
@@ -21,31 +21,31 @@
{# Desktop: 7-column grid (East R1 | R2 | CF | Cup | CF | R2 | R1 West) #} {# Desktop: 7-column grid (East R1 | R2 | CF | Cup | CF | R2 | R1 West) #}
<section class="bracket-grid" aria-label="Full playoff bracket"> <section class="bracket-grid" aria-label="Full playoff bracket">
<div class="bracket-col bracket-col-r1 bracket-col-east"> <div class="bracket-col bracket-col-r1 bracket-col-east">
<h2 class="bracket-col-heading">First Round</h2> <h2 class="bracket-col-heading {% if bracket.current_round == 1 %}bracket-col-active{% endif %}">First Round</h2>
{% for m in bracket.east_r1 %}{% include "_bracket_matchup.html" %}{% endfor %} {% for m in bracket.east_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div> </div>
<div class="bracket-col bracket-col-r2 bracket-col-east"> <div class="bracket-col bracket-col-r2 bracket-col-east">
<h2 class="bracket-col-heading">Second Round</h2> <h2 class="bracket-col-heading {% if bracket.current_round == 2 %}bracket-col-active{% endif %}">Second Round</h2>
{% for m in bracket.east_r2 %}{% include "_bracket_matchup.html" %}{% endfor %} {% for m in bracket.east_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div> </div>
<div class="bracket-col bracket-col-cf bracket-col-east"> <div class="bracket-col bracket-col-cf bracket-col-east">
<h2 class="bracket-col-heading">East Final</h2> <h2 class="bracket-col-heading {% if bracket.current_round == 3 %}bracket-col-active{% endif %}">East Final</h2>
{% for m in bracket.east_cf %}{% include "_bracket_matchup.html" %}{% endfor %} {% for m in bracket.east_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
</div> </div>
<div class="bracket-col bracket-col-cup"> <div class="bracket-col bracket-col-cup">
<h2 class="bracket-col-heading bracket-cup-heading">Cup Final</h2> <h2 class="bracket-col-heading bracket-cup-heading {% if bracket.current_round == 4 %}bracket-col-active{% endif %}">Cup Final</h2>
{% for m in bracket.cup %}{% include "_bracket_matchup.html" %}{% endfor %} {% for m in bracket.cup %}{% include "_bracket_matchup.html" %}{% endfor %}
</div> </div>
<div class="bracket-col bracket-col-cf bracket-col-west"> <div class="bracket-col bracket-col-cf bracket-col-west">
<h2 class="bracket-col-heading">West Final</h2> <h2 class="bracket-col-heading {% if bracket.current_round == 3 %}bracket-col-active{% endif %}">West Final</h2>
{% for m in bracket.west_cf %}{% include "_bracket_matchup.html" %}{% endfor %} {% for m in bracket.west_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
</div> </div>
<div class="bracket-col bracket-col-r2 bracket-col-west"> <div class="bracket-col bracket-col-r2 bracket-col-west">
<h2 class="bracket-col-heading">Second Round</h2> <h2 class="bracket-col-heading {% if bracket.current_round == 2 %}bracket-col-active{% endif %}">Second Round</h2>
{% for m in bracket.west_r2 %}{% include "_bracket_matchup.html" %}{% endfor %} {% for m in bracket.west_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div> </div>
<div class="bracket-col bracket-col-r1 bracket-col-west"> <div class="bracket-col bracket-col-r1 bracket-col-west">
<h2 class="bracket-col-heading">First Round</h2> <h2 class="bracket-col-heading {% if bracket.current_round == 1 %}bracket-col-active{% endif %}">First Round</h2>
{% for m in bracket.west_r1 %}{% include "_bracket_matchup.html" %}{% endfor %} {% for m in bracket.west_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div> </div>
</section> </section>
@@ -53,7 +53,7 @@
{# Mobile: round-by-round accordion, round 1 open by default #} {# Mobile: round-by-round accordion, round 1 open by default #}
<section class="bracket-accordion" aria-label="Playoff bracket by round"> <section class="bracket-accordion" aria-label="Playoff bracket by round">
{% for rnd in bracket.rounds %} {% for rnd in bracket.rounds %}
<details class="bracket-round" {% if loop.first %}open{% endif %}> <details class="bracket-round" {% if rnd.round_num == bracket.current_round or (bracket.current_round is none and loop.first) %}open{% endif %}>
<summary class="bracket-round-summary">{{ rnd.label }}</summary> <summary class="bracket-round-summary">{{ rnd.label }}</summary>
<div class="bracket-round-body"> <div class="bracket-round-body">
{% if rnd.get('east') %} {% if rnd.get('east') %}
+10
View File
@@ -15,6 +15,11 @@
<body> <body>
<header> <header>
<span class="header-title">NHL Scoreboard</span> <span class="header-title">NHL Scoreboard</span>
<nav class="date-nav" aria-label="Date navigation">
<button id="date-prev" class="date-btn" aria-label="Previous day">&larr;</button>
<span id="date-label" class="date-label"></span>
<button id="date-next" class="date-btn" aria-label="Next day">&rarr;</button>
</nav>
</header> </header>
<section id="playoff-banner" class="playoff-banner hidden" aria-hidden="true"> <section id="playoff-banner" class="playoff-banner hidden" aria-hidden="true">
<a class="banner-main" href="/bracket" aria-label="View the playoff bracket"> <a class="banner-main" href="/bracket" aria-label="View the playoff bracket">
@@ -45,7 +50,12 @@
</div> </div>
</a> </a>
</section> </section>
<div id="stale-banner" class="stale-banner hidden">Connection lost &mdash; scores may be outdated</div>
<main> <main>
<div id="empty-state" class="empty-state hidden">
<p class="empty-state-heading">No games scheduled today</p>
<p class="empty-state-sub">Check back tomorrow</p>
</div>
<section id="pinned-section" class="section pinned-section hidden"> <section id="pinned-section" class="section pinned-section hidden">
<h2 class="section-heading section-heading-gold">Spotlight &middot; Game 7</h2> <h2 class="section-heading section-heading-gold">Spotlight &middot; Game 7</h2>
<div id="pinned-games-section" class="games-grid"></div> <div id="pinned-games-section" class="games-grid"></div>
+1
View File
@@ -7,6 +7,7 @@
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="{{ static_v('icon-32x32.png') }}"> <link rel="icon" type="image/png" href="{{ static_v('icon-32x32.png') }}">
<link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}"> <link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}">
{% if series.has_live %}<meta http-equiv="refresh" content="30">{% endif %}
</head> </head>
<body class="playoff-mode series-mode"> <body class="playoff-mode series-mode">
<header class="series-header"> <header class="series-header">