2da60e27ae
- Stale data banner after 3 consecutive fetch failures, auto-clears on recovery - Date navigation with left/right arrows (Yesterday/Today/Tomorrow labels), fetches from NHL API for non-today dates, disables auto-refresh on history - Empty state message when no games are scheduled - Series detail page auto-refreshes every 30s when a game is live - Notification permission deferred until a playoff OT actually occurs - Scroll position saved/restored when navigating to/from series detail - Team records rendered with better contrast and tabular nums - Active bracket round highlighted with gold heading + underline, completed rounds dimmed more aggressively, mobile accordion auto-opens current round - Browser tab title shows live game count (e.g. "NHL Scoreboard (3 Live)") - Service worker update shows a dismissable toast instead of force-reloading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
146 lines
4.4 KiB
Python
146 lines
4.4 KiB
Python
"""Normalize NHL /v1/playoff-bracket payloads for the bracket template.
|
|
|
|
The NHL bracket uses stable series letters:
|
|
A,B,C,D = Round 1 East E,F,G,H = Round 1 West
|
|
I,J = Round 2 East K,L = Round 2 West
|
|
M = Conf Final East N = Conf Final West
|
|
O = Stanley Cup Final
|
|
"""
|
|
|
|
from app.playoff import ROUND_LABELS
|
|
|
|
EAST_R1 = ["A", "B", "C", "D"]
|
|
WEST_R1 = ["E", "F", "G", "H"]
|
|
EAST_R2 = ["I", "J"]
|
|
WEST_R2 = ["K", "L"]
|
|
EAST_CF = ["M"]
|
|
WEST_CF = ["N"]
|
|
CUP_FINAL = ["O"]
|
|
|
|
|
|
def build_bracket_view(year, bracket_payload, fetched_at=None):
|
|
"""Shape the raw bracket API payload for bracket.html.
|
|
|
|
Returns a dict of rounds grouped by conference, plus a flat `matchups` list
|
|
keyed by letter for the mobile accordion. Missing letters render as empty
|
|
placeholder slots so the grid stays visually complete before upsets decide.
|
|
"""
|
|
series_by_letter = {}
|
|
for s in (bracket_payload or {}).get("series", []):
|
|
letter = s.get("seriesLetter")
|
|
if letter:
|
|
series_by_letter[letter] = s
|
|
|
|
def slot(letter):
|
|
return _matchup(year, letter, series_by_letter.get(letter))
|
|
|
|
east_r1 = [slot(ltr) for ltr in EAST_R1]
|
|
west_r1 = [slot(ltr) for ltr in WEST_R1]
|
|
east_r2 = [slot(ltr) for ltr in EAST_R2]
|
|
west_r2 = [slot(ltr) for ltr in WEST_R2]
|
|
east_cf = [slot(ltr) for ltr in EAST_CF]
|
|
west_cf = [slot(ltr) for ltr in WEST_CF]
|
|
cup = [slot(ltr) for ltr in CUP_FINAL]
|
|
|
|
all_rounds = [
|
|
(1, east_r1 + west_r1),
|
|
(2, east_r2 + west_r2),
|
|
(3, east_cf + west_cf),
|
|
(4, cup),
|
|
]
|
|
current_round = None
|
|
for r, matchups in all_rounds:
|
|
if any(m["state"] == "active" for m in matchups):
|
|
current_round = r
|
|
break
|
|
|
|
return {
|
|
"year": year,
|
|
"fetched_at": fetched_at,
|
|
"bracket_logo": (bracket_payload or {}).get("bracketLogo"),
|
|
"current_round": current_round,
|
|
"east_r1": east_r1,
|
|
"west_r1": west_r1,
|
|
"east_r2": east_r2,
|
|
"west_r2": west_r2,
|
|
"east_cf": east_cf,
|
|
"west_cf": west_cf,
|
|
"cup": cup,
|
|
"rounds": [
|
|
{"label": ROUND_LABELS[1], "round_num": 1, "east": east_r1, "west": west_r1},
|
|
{"label": ROUND_LABELS[2], "round_num": 2, "east": east_r2, "west": west_r2},
|
|
{"label": ROUND_LABELS[3], "round_num": 3, "east": east_cf, "west": west_cf},
|
|
{"label": ROUND_LABELS[4], "round_num": 4, "cup": cup},
|
|
],
|
|
}
|
|
|
|
|
|
def _matchup(year, letter, series):
|
|
"""Render-ready dict for one bracket slot. Empty when the series is unknown."""
|
|
if not series:
|
|
return {
|
|
"letter": letter,
|
|
"series_id": f"{year}-{letter}",
|
|
"empty": True,
|
|
"top": None,
|
|
"bottom": None,
|
|
"top_wins": 0,
|
|
"bottom_wins": 0,
|
|
"round": None,
|
|
"winner_abbrev": None,
|
|
"state": "pending",
|
|
}
|
|
|
|
top = series.get("topSeedTeam") or {}
|
|
bot = series.get("bottomSeedTeam") or {}
|
|
top_wins = _to_int(series.get("topSeedWins"))
|
|
bot_wins = _to_int(series.get("bottomSeedWins"))
|
|
winning_id = series.get("winningTeamId")
|
|
|
|
winner_abbrev = None
|
|
if winning_id is not None:
|
|
if top.get("id") == winning_id:
|
|
winner_abbrev = top.get("abbrev")
|
|
elif bot.get("id") == winning_id:
|
|
winner_abbrev = bot.get("abbrev")
|
|
|
|
if winner_abbrev:
|
|
state = "complete"
|
|
elif top_wins > 0 or bot_wins > 0:
|
|
state = "active"
|
|
else:
|
|
state = "upcoming"
|
|
|
|
return {
|
|
"letter": letter,
|
|
"series_id": f"{year}-{letter}",
|
|
"empty": False,
|
|
"top": _team(top, series.get("topSeedRankAbbrev")),
|
|
"bottom": _team(bot, series.get("bottomSeedRankAbbrev")),
|
|
"top_wins": top_wins,
|
|
"bottom_wins": bot_wins,
|
|
"round": series.get("playoffRound"),
|
|
"winner_abbrev": winner_abbrev,
|
|
"state": state,
|
|
}
|
|
|
|
|
|
def _team(team, seed_abbrev=None):
|
|
if not team:
|
|
return None
|
|
return {
|
|
"id": team.get("id"),
|
|
"abbrev": team.get("abbrev"),
|
|
"name": (team.get("name") or {}).get("default"),
|
|
"common_name": (team.get("commonName") or {}).get("default"),
|
|
"logo": team.get("darkLogo") or team.get("logo"),
|
|
"seed": seed_abbrev,
|
|
}
|
|
|
|
|
|
def _to_int(v, default=0):
|
|
try:
|
|
return int(v)
|
|
except (TypeError, ValueError):
|
|
return default
|