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>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user