Files
NHL-Scoreboard/app/series_view.py
T
josh ebe770fecd
CI / Lint (push) Failing after 8s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
Turn a regular-season-looking Tuesday into a full playoff experience:

- Playoff banner with round + day + series + elimination counts, gold/silver
  Cup theme toggled by body.playoff-mode
- Series context on each playoff card: round chip, series score, stake badges
  (GAME 7, CLINCHER, PIVOTAL), and one-line blurb
- Game 7s pin to a new Spotlight section above Live
- Playoff OT renders with SUDDEN DEATH badge and pulsing gold border
- Client-side OT notifications via bell button in the banner
- New /series/<id> drill-down with headline, next-game, and game-by-game history
- New /bracket page: 7-column desktop grid, accordion on mobile
- Day N banner count auto-anchors on first playoff scoreboard hit
- SQLite cache for bracket + per-series schedules, stale-on-failure up to 24h

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 12:47:31 -04:00

194 lines
6.6 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,
"headline": _headline(state, leader_team, trailer_team, top_wins, bot_wins),
"games": normalized_games,
"played_games": played,
"next_game": next_game,
"series_logo": payload.get("seriesLogo"),
}
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 _headline(state, leader, trailer, top_wins, bot_wins):
if state["is_game7"]:
return "Win-or-go-home \u2014 Game 7 tonight."
if state["is_clincher"] and leader:
return f"{leader['full']} can close it out in Game {state['game_number']}."
if state["is_pivotal"]:
return f"Series tied 2\u20112 \u2014 pivotal Game {state['game_number']}."
if state["is_opener"]:
return "Series opener."
if leader and trailer:
return (
f"{leader['full']} leads {state['hi']}\u2011{state['lo']} "
f"\u2014 Game {state['game_number']} next."
)
return f"Series even {top_wins}\u2011{bot_wins}."
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 "", ""
return dt.strftime("%-I:%M %p ET"), dt.strftime("%a %b %-d")
def _to_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default