"""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"), } 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 "", "" 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