Files
josh f99738d2e4
CI / Lint (push) Successful in 8s
CI / Test (push) Successful in 11s
CI / Build & Push (push) Successful in 20s
fix: show correct "Game X of 7" for future playoff dates
Enrich raw score-endpoint games with gameNumber from the series cache
before parsing. The score API omits gameNumber and its seriesStatus
reflects current wins, so all future games in a series computed the
same number. Now we cross-reference by game id against the cached
series-detail endpoint which includes the correct gameNumber per game.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 15:39:44 -04:00

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"