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>
273 lines
8.9 KiB
Python
273 lines
8.9 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 series_blurb(game):
|
|
"""One sentence of series context for a playoff card."""
|
|
state = series_state(game.get("seriesStatus", {}))
|
|
g = state["game_number"]
|
|
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} leads {state['hi']}\u2011{state['lo']} \u2014 Game {g}."
|
|
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 series-score line rendered under the blurb, e.g. 'LAK leads 2-1'."""
|
|
state = series_state(game.get("seriesStatus", {}))
|
|
if state["is_opener"]:
|
|
return f"Game 1 of 7 \u00b7 Round {state['round']}"
|
|
leader_name = _leader_name(game, state)
|
|
if leader_name:
|
|
return f"{leader_name} leads {state['hi']}\u2011{state['lo']}"
|
|
return f"Series tied {state['hi']}\u2011{state['lo']}"
|
|
|
|
|
|
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, day_total=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` / `day_total` are injected by the caller (from the playoff_cache
|
|
module) to keep this function pure and test-friendly.
|
|
"""
|
|
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,
|
|
"day_total": 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,
|
|
"day_total": day_total,
|
|
"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"
|