Files
NHL-Scoreboard/app/series_view.py
T
josh 2da60e27ae
CI / Lint (push) Failing after 10s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
feat: add 10 UX improvements from interface review
- 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>
2026-04-23 20:22:03 -04:00

180 lines
6.0 KiB
Python

"""Normalize NHL /v1/schedule/playoff-series payloads for the series template.
The API payload is verbose and nested; this module flattens it into a small
render-ready dict so series.html can stay simple.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from app.playoff import ROUND_LABELS, ot_label, series_state
EASTERN = ZoneInfo("America/New_York")
_STATE_LABELS = {
"FUT": "Scheduled",
"PRE": "Pregame",
"LIVE": "Live",
"CRIT": "Live",
"OFF": "Final",
"FINAL": "Final",
}
def build_series_view(series_id, payload):
"""Return a dict shaped for rendering in series.html.
`payload` is the raw JSON from /v1/schedule/playoff-series/{season}/{letter}.
"""
top = payload.get("topSeedTeam", {}) or {}
bot = payload.get("bottomSeedTeam", {}) or {}
games = payload.get("games", []) or []
top_wins = _to_int(top.get("seriesWins"))
bot_wins = _to_int(bot.get("seriesWins"))
needed = _to_int(payload.get("neededToWin"), default=4)
state = series_state(
{
"round": _to_int(payload.get("round"), default=1),
"topSeedWins": top_wins,
"bottomSeedWins": bot_wins,
"topSeedTeamAbbrev": top.get("abbrev"),
"bottomSeedTeamAbbrev": bot.get("abbrev"),
}
)
leader_team = None
trailer_team = None
if state["leader"] == "top":
leader_team, trailer_team = _team_view(top), _team_view(bot)
elif state["leader"] == "bottom":
leader_team, trailer_team = _team_view(bot), _team_view(top)
normalized_games = [_game_view(g) for g in games]
played = [g for g in normalized_games if g["state_group"] == "completed"]
upcoming = [g for g in normalized_games if g["state_group"] != "completed"]
next_game = upcoming[0] if upcoming else None
round_num = _to_int(payload.get("round"), default=1)
return {
"series_id": series_id,
"round": round_num,
"round_label": payload.get("roundLabel")
or ROUND_LABELS.get(round_num, f"Round {round_num}"),
"series_letter": payload.get("seriesLetter"),
"needed_to_win": needed,
"length": _to_int(payload.get("length"), default=7),
"top": _team_view(top),
"bottom": _team_view(bot),
"top_wins": top_wins,
"bottom_wins": bot_wins,
"leader": leader_team,
"trailer": trailer_team,
"state": state,
"games": normalized_games,
"played_games": played,
"next_game": next_game,
"series_logo": payload.get("seriesLogo"),
"has_live": any(g["live"] for g in normalized_games),
}
def _team_view(team):
if not team:
return None
name = (team.get("name") or {}).get("default") or team.get("abbrev", "")
place = (team.get("placeName") or {}).get("default") or ""
return {
"id": team.get("id"),
"name": name,
"place": place,
"full": f"{place} {name}".strip() if place else name,
"abbrev": team.get("abbrev"),
"logo": team.get("darkLogo") or team.get("logo"),
"record": team.get("record"),
"seed": team.get("seed"),
"series_wins": _to_int(team.get("seriesWins")),
"division": team.get("divisionAbbrev"),
"conference": (team.get("conference") or {}).get("abbrev"),
}
def _game_view(game):
gs = game.get("gameState", "")
state_label = _STATE_LABELS.get(gs, gs.title() if gs else "Scheduled")
completed = gs in ("OFF", "FINAL")
live = gs in ("LIVE", "CRIT")
home = game.get("homeTeam", {}) or {}
away = game.get("awayTeam", {}) or {}
start_local, start_date = _format_start(game.get("startTimeUTC"))
last_period = (game.get("gameOutcome") or {}).get("lastPeriodType") or ""
period_num = _to_int((game.get("periodDescriptor") or {}).get("number"))
ended_in_ot = completed and last_period == "OT"
ended_multi_ot = completed and period_num >= 4 and last_period == "OT"
winner_abbrev = None
if completed:
home_score = _to_int(home.get("score"))
away_score = _to_int(away.get("score"))
if home_score > away_score:
winner_abbrev = home.get("abbrev")
elif away_score > home_score:
winner_abbrev = away.get("abbrev")
return {
"id": game.get("id"),
"game_number": _to_int(game.get("gameNumber"), default=1),
"if_necessary": bool(game.get("ifNecessary")),
"venue": (game.get("venue") or {}).get("default", ""),
"start_utc": game.get("startTimeUTC"),
"start_local": start_local,
"start_date": start_date,
"state": gs,
"state_label": state_label,
"state_group": "completed" if completed else ("live" if live else "upcoming"),
"live": live,
"period_number": period_num,
"period_ot_label": ot_label(period_num) if live and period_num >= 4 else "",
"ended_in_ot": ended_in_ot,
"ended_in_multi_ot": ended_multi_ot,
"home": {
"abbrev": home.get("abbrev"),
"name": (home.get("commonName") or {}).get("default"),
"place": (home.get("placeName") or {}).get("default"),
"score": _to_int(home.get("score")) if completed or live else None,
},
"away": {
"abbrev": away.get("abbrev"),
"name": (away.get("commonName") or {}).get("default"),
"place": (away.get("placeName") or {}).get("default"),
"score": _to_int(away.get("score")) if completed or live else None,
},
"winner_abbrev": winner_abbrev,
}
def _format_start(start_utc):
if not start_utc:
return "", ""
try:
dt = datetime.fromisoformat(start_utc.replace("Z", "+00:00")).astimezone(
EASTERN
)
except ValueError:
return "", ""
hour = dt.strftime("%I").lstrip("0") or "12"
time_str = f"{hour}:{dt.strftime('%M %p')} ET"
date_str = f"{dt.strftime('%a %b')} {dt.day}"
return time_str, date_str
def _to_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default