294 lines
10 KiB
Python
294 lines
10 KiB
Python
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. seriesStatus counts wins through the current
|
|
payload, so once a game goes FINAL the win for this game is already banked
|
|
and state['game_number'] (hi+lo+1) points at the *next* game. For a finished
|
|
card, pin to hi+lo. The scoreboard payload doesn't carry a raw gameNumber,
|
|
but we honor it if present (e.g. from the series-detail endpoint)."""
|
|
raw = game.get("gameNumber")
|
|
if isinstance(raw, int) and raw > 0:
|
|
return raw
|
|
if game.get("gameState") in ("FINAL", "OFF"):
|
|
return max(1, state["hi"] + state["lo"])
|
|
return state["game_number"]
|
|
|
|
|
|
def series_blurb(game):
|
|
"""One sentence of series context for a playoff card."""
|
|
state = series_state(game.get("seriesStatus", {}))
|
|
leader_name = _leader_name(game, state)
|
|
trailer_name = _trailer_name(game, state)
|
|
is_final = game.get("gameState") in ("FINAL", "OFF")
|
|
|
|
# Stake / opener blurbs describe what's *about* to happen. For a FINAL card
|
|
# the seriesStatus already includes this game, so the stake really points at
|
|
# the next matchup \u2014 fall through to a generic series-score blurb instead.
|
|
if not is_final:
|
|
if state["is_game7"]:
|
|
return "Win-or-go-home"
|
|
if state["is_clincher"] and leader_name:
|
|
return f"{leader_name} can close it out"
|
|
if state["is_pivotal"]:
|
|
return "Series tied 2\u20112 \u2014 pivotal"
|
|
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']}"
|
|
return ""
|
|
|
|
|
|
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)
|
|
|
|
# Stake badges describe the *upcoming* game. Once a game is FINAL the
|
|
# seriesStatus reflects post-game wins, so the predicate now points at the
|
|
# next card in the series — don't stamp it onto the one that's already done.
|
|
if game.get("gameState") not in ("FINAL", "OFF"):
|
|
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"])
|
|
# Only pending/live games can still become the clincher or Game 7
|
|
# today. Once a card is FINAL its seriesStatus points at the next game.
|
|
if g.get("gameState") in ("FINAL", "OFF"):
|
|
continue
|
|
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"
|