Files
NHL-Scoreboard/app/playoff.py
T
josh ebe770fecd
CI / Lint (push) Failing after 8s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
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>
2026-04-19 12:47:31 -04:00

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"