ebe770fecd
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>
194 lines
6.6 KiB
Python
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
|