from datetime import datetime from zoneinfo import ZoneInfo EASTERN = ZoneInfo("America/New_York") ROUND_LABELS = { 1: "First Round", 2: "Second Round", 3: "Conference Finals", 4: "Stanley Cup Final", } def is_playoff_game(game): return game.get("gameType", game.get("Game Type", 2)) == 3 def series_id(game): """Return '{year}-{letter}' for a playoff game, or None if unavailable. Year is derived from `startTimeUTC` (Eastern) and falls back to current Eastern year. Letter comes from `seriesStatus.seriesLetter` (or the legacy `seriesAbbrev` field). """ if not is_playoff_game(game): return None ss = game.get("seriesStatus") or {} letter = ss.get("seriesLetter") or ss.get("seriesAbbrev") if not letter: return None start = game.get("startTimeUTC") or "" try: year = ( datetime.fromisoformat(start.replace("Z", "+00:00")) .astimezone(EASTERN) .year ) except (ValueError, AttributeError): year = datetime.now(EASTERN).year return f"{year}-{letter.upper()}" def series_state(series_status): """Pure function of a raw seriesStatus dict. Returns a dict of predicates + derived values. When seriesStatus is empty (playoff game reported before the API has filled in the matchup) the state degrades gracefully — all predicates False, game_number 1, round 1. """ if not series_status: return { "round": 1, "top_wins": 0, "bottom_wins": 0, "hi": 0, "lo": 0, "leader": None, "game_number": 1, "is_game7": False, "is_clincher": False, "is_elimination": False, "is_pivotal": False, "is_opener": True, } round_num = series_status.get("round", 1) top = series_status.get("topSeedWins", 0) bot = series_status.get("bottomSeedWins", 0) hi = max(top, bot) lo = min(top, bot) if top > bot: leader = "top" elif bot > top: leader = "bottom" else: leader = None return { "round": round_num, "top_wins": top, "bottom_wins": bot, "hi": hi, "lo": lo, "leader": leader, "game_number": top + bot + 1, "is_game7": hi == 3 and lo == 3, "is_clincher": hi == 3 and lo < 3, "is_elimination": hi == 3 and lo < 3, "is_pivotal": hi == 2 and lo == 2, "is_opener": hi == 0 and lo == 0, } def _game_number(game, state): """This card's game number. Prefer the raw ``gameNumber`` field so we don't drift forward once ``seriesStatus`` advances after the game ends.""" raw = game.get("gameNumber") if isinstance(raw, int) and raw > 0: return raw return state["game_number"] def series_blurb(game): """One sentence of series context for a playoff card.""" state = series_state(game.get("seriesStatus", {})) g = _game_number(game, state) leader_name = _leader_name(game, state) trailer_name = _trailer_name(game, state) if state["is_game7"]: return "Win-or-go-home \u2014 Game 7." if state["is_clincher"] and leader_name: return f"{leader_name} can close it out \u2014 Game {g}." if state["is_pivotal"]: return f"Series tied 2\u20112 \u2014 pivotal Game {g}." if state["is_opener"]: return "Series opener" if leader_name and trailer_name: return f"{leader_name} lead {state['hi']}\u2011{state['lo']}" if state["hi"] == state["lo"]: return f"Series even at {state['hi']}\u2011{state['lo']} \u2014 Game {g}." return f"Game {g}." def series_badges(game): """Ordered list of stake labels to render as chip-badges on the card.""" state = series_state(game.get("seriesStatus", {})) badges = [] round_num = state["round"] round_abbrev = {1: "R1", 2: "R2", 3: "CONF FINAL", 4: "CUP FINAL"}.get( round_num, f"R{round_num}" ) badges.append(round_abbrev) if state["is_game7"]: badges.append("GAME 7") elif state["is_clincher"]: badges.append("CLINCHER") elif state["is_pivotal"]: badges.append("PIVOTAL") return badges def series_summary(game): """Short line rendered above the card, e.g. 'Game 2 of 7'.""" state = series_state(game.get("seriesStatus", {})) return f"Game {_game_number(game, state)} of 7" def is_pinned(game): if not is_playoff_game(game): return False state = series_state(game.get("seriesStatus", {})) if not state["is_game7"]: return False gs = game.get("gameState", "") return gs in ("LIVE", "CRIT", "PRE", "FUT") def is_playoff_ot(game): if not is_playoff_game(game): return False gs = game.get("gameState", "") if gs not in ("LIVE", "CRIT"): return False period = game.get("periodDescriptor", {}).get("number", 0) return period >= 4 def ot_label(period): """'OT', '2OT', '3OT', ... from raw period number (4 = 1st OT).""" if period < 4: return "" n = period - 3 return "OT" if n == 1 else f"{n}OT" def today_meta(raw_games, now=None, day_n=None): """Build the banner payload from the raw NHL games list. `raw_games` is the list inside the NHL score response (each with gameType, seriesStatus, etc.) — NOT the parsed game dicts. This keeps the dependency one-way: playoff.py doesn't need to know parse_games' field names. `day_n` is injected by the caller (from the playoff_cache module) scoped to the max observed round so the banner resets at each round boundary. """ playoff_games = [g for g in raw_games if g.get("gameType") == 3] playoff_mode = len(playoff_games) > 0 if not playoff_mode: return { "playoff_mode": False, "round_label": None, "day_n": None, "series_active": 0, "elimination_count": 0, "game7_count": 0, "year": _year(now), } series_letters = set() elim = 0 g7 = 0 max_round = 1 for g in playoff_games: ss = g.get("seriesStatus") or {} letter = ss.get("seriesLetter") or ss.get("seriesAbbrev") if letter: series_letters.add(letter) state = series_state(ss) max_round = max(max_round, state["round"]) if state["is_game7"]: g7 += 1 elif state["is_clincher"]: elim += 1 return { "playoff_mode": True, "round_label": ROUND_LABELS.get(max_round, f"Round {max_round}"), "day_n": day_n, "series_active": len(series_letters) if series_letters else len(playoff_games), "elimination_count": elim, "game7_count": g7, "year": _year(now), } def _year(now): now = now or datetime.now(EASTERN) # NHL seasons span two calendar years; the playoff year is the later one. # April onward = current calendar year; Jan-March = previous year's playoffs # only if we're still in the prior season, but playoffs start in April, so # reporting `now.year` is correct during any active playoff window. return now.year def _leader_name(game, state): """Return the common name of the series-leading team, or None.""" if state["leader"] is None: return None top_team = (game.get("seriesStatus") or {}).get("topSeedTeamAbbrev") bottom_team = (game.get("seriesStatus") or {}).get("bottomSeedTeamAbbrev") home = game.get("homeTeam", {}).get("name", {}).get("default") away = game.get("awayTeam", {}).get("name", {}).get("default") home_abbrev = game.get("homeTeam", {}).get("abbrev") away_abbrev = game.get("awayTeam", {}).get("abbrev") leader_abbrev = top_team if state["leader"] == "top" else bottom_team if leader_abbrev and home_abbrev and leader_abbrev == home_abbrev: return home if leader_abbrev and away_abbrev and leader_abbrev == away_abbrev: return away # Fallback — the seriesStatus didn't include seed abbreviations. The best # we can do without the bracket cache is report by seed label. return "Top seed" if state["leader"] == "top" else "Bottom seed" def _trailer_name(game, state): if state["leader"] is None: return None top_team = (game.get("seriesStatus") or {}).get("topSeedTeamAbbrev") bottom_team = (game.get("seriesStatus") or {}).get("bottomSeedTeamAbbrev") home = game.get("homeTeam", {}).get("name", {}).get("default") away = game.get("awayTeam", {}).get("name", {}).get("default") home_abbrev = game.get("homeTeam", {}).get("abbrev") away_abbrev = game.get("awayTeam", {}).get("abbrev") trailer_abbrev = bottom_team if state["leader"] == "top" else top_team if trailer_abbrev and home_abbrev and trailer_abbrev == home_abbrev: return home if trailer_abbrev and away_abbrev and trailer_abbrev == away_abbrev: return away return "Bottom seed" if state["leader"] == "top" else "Top seed"