feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
CI / Lint (push) Failing after 8s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped

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>
This commit is contained in:
2026-04-19 12:47:31 -04:00
parent e0db8f0859
commit ebe770fecd
21 changed files with 3163 additions and 31 deletions
+124
View File
@@ -0,0 +1,124 @@
from app.bracket_view import build_bracket_view
def _series(letter, top_abbrev, top_id, top_wins, bot_abbrev, bot_id, bot_wins,
rnd=1, winning_id=None, top_seed="D1", bot_seed="WC1"):
return {
"seriesLetter": letter,
"playoffRound": rnd,
"topSeedWins": top_wins,
"bottomSeedWins": bot_wins,
"topSeedRankAbbrev": top_seed,
"bottomSeedRankAbbrev": bot_seed,
"winningTeamId": winning_id,
"topSeedTeam": {
"id": top_id,
"abbrev": top_abbrev,
"name": {"default": f"{top_abbrev} Team"},
"commonName": {"default": top_abbrev},
"darkLogo": f"http://example.com/{top_abbrev}_dark.svg",
},
"bottomSeedTeam": {
"id": bot_id,
"abbrev": bot_abbrev,
"name": {"default": f"{bot_abbrev} Team"},
"commonName": {"default": bot_abbrev},
"darkLogo": f"http://example.com/{bot_abbrev}_dark.svg",
},
}
class TestEmptyBracket:
def test_empty_payload_returns_all_placeholders(self):
view = build_bracket_view(2026, {"series": []})
assert len(view["east_r1"]) == 4
assert len(view["west_r1"]) == 4
assert len(view["east_r2"]) == 2
assert len(view["west_r2"]) == 2
assert len(view["east_cf"]) == 1
assert len(view["west_cf"]) == 1
assert len(view["cup"]) == 1
for slot in view["east_r1"]:
assert slot["empty"] is True
assert slot["series_id"].startswith("2026-")
def test_none_payload_is_safe(self):
view = build_bracket_view(2026, None)
assert all(s["empty"] for s in view["east_r1"])
class TestMatchupStates:
def test_complete_series_marks_winner(self):
s = _series("A", "TOR", 10, 4, "OTT", 9, 2, winning_id=10)
view = build_bracket_view(2026, {"series": [s]})
a = view["east_r1"][0]
assert a["empty"] is False
assert a["state"] == "complete"
assert a["winner_abbrev"] == "TOR"
assert a["top_wins"] == 4
assert a["bottom_wins"] == 2
def test_active_series_has_wins_but_no_winner(self):
s = _series("B", "FLA", 13, 2, "TBL", 14, 1)
view = build_bracket_view(2026, {"series": [s]})
b = view["east_r1"][1]
assert b["state"] == "active"
assert b["winner_abbrev"] is None
def test_upcoming_series_zero_zero(self):
s = _series("C", "WSH", 15, 0, "MTL", 8, 0)
view = build_bracket_view(2026, {"series": [s]})
c = view["east_r1"][2]
assert c["state"] == "upcoming"
class TestRoutingToRounds:
def test_round_1_east_vs_west_by_letter(self):
series = [
_series("A", "T1", 1, 1, "T2", 2, 0),
_series("E", "T3", 3, 1, "T4", 4, 0),
]
view = build_bracket_view(2026, {"series": series})
assert view["east_r1"][0]["top"]["abbrev"] == "T1"
assert view["west_r1"][0]["top"]["abbrev"] == "T3"
def test_round_2_routing(self):
series = [
_series("I", "T1", 1, 0, "T2", 2, 0, rnd=2),
_series("K", "T3", 3, 0, "T4", 4, 0, rnd=2),
]
view = build_bracket_view(2026, {"series": series})
assert view["east_r2"][0]["top"]["abbrev"] == "T1"
assert view["west_r2"][0]["top"]["abbrev"] == "T3"
def test_conf_finals_routing(self):
series = [
_series("M", "T1", 1, 0, "T2", 2, 0, rnd=3),
_series("N", "T3", 3, 0, "T4", 4, 0, rnd=3),
]
view = build_bracket_view(2026, {"series": series})
assert view["east_cf"][0]["top"]["abbrev"] == "T1"
assert view["west_cf"][0]["top"]["abbrev"] == "T3"
def test_cup_final_routing(self):
series = [_series("O", "T1", 1, 2, "T2", 2, 4, rnd=4, winning_id=2)]
view = build_bracket_view(2026, {"series": series})
assert view["cup"][0]["bottom"]["abbrev"] == "T2"
assert view["cup"][0]["winner_abbrev"] == "T2"
class TestSeriesIdLink:
def test_series_id_format(self):
s = _series("A", "TOR", 10, 0, "OTT", 9, 0)
view = build_bracket_view(2026, {"series": [s]})
assert view["east_r1"][0]["series_id"] == "2026-A"
class TestRoundsAccordionBundle:
def test_rounds_has_four_entries(self):
view = build_bracket_view(2026, {"series": []})
assert len(view["rounds"]) == 4
assert view["rounds"][0]["label"] == "First Round"
assert view["rounds"][3]["label"] == "Stanley Cup Final"
assert "east" in view["rounds"][0]
assert "cup" in view["rounds"][3]