Files
NHL-Scoreboard/app/bracket_view.py
T
josh 2da60e27ae
CI / Lint (push) Failing after 10s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
feat: add 10 UX improvements from interface review
- 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>
2026-04-23 20:22:03 -04:00

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