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
+217 -1
View File
@@ -1,6 +1,6 @@
import json
from tests.conftest import make_game
from tests.conftest import make_game, make_playoff_game
class TestIndexRoute:
@@ -86,3 +86,219 @@ class TestScoreboardRoute:
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
assert "error" in data
def test_meta_and_pinned_keys_present(self, flask_client):
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
assert "meta" in data
assert "pinned_games" in data
assert "playoff_mode" in data["meta"]
def test_meta_playoff_mode_off_for_regular_season(self, flask_client):
response = flask_client.get("/scoreboard")
data = json.loads(response.data)
assert data["meta"]["playoff_mode"] is False
def test_playoff_day_populates_meta(self, flask_client, monkeypatch, tmp_path):
import app.routes as routes
playoff_game = make_playoff_game(
top_wins=3,
bottom_wins=3,
round_num=1,
series_letter="A",
game_state="LIVE",
)
scoreboard = {"games": [playoff_game]}
f = tmp_path / "scoreboard_data.json"
f.write_text(json.dumps(scoreboard))
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(f))
data = json.loads(flask_client.get("/scoreboard").data)
assert data["meta"]["playoff_mode"] is True
assert data["meta"]["round_label"] == "First Round"
assert data["meta"]["game7_count"] == 1
assert data["meta"]["series_active"] == 1
def test_game7_goes_to_pinned_bucket_not_live(
self, flask_client, monkeypatch, tmp_path
):
import app.routes as routes
g7 = make_playoff_game(
top_wins=3,
bottom_wins=3,
game_state="LIVE",
home_name="Kings",
away_name="Oilers",
)
regular_live = make_game(home_name="Rangers", away_name="Devils")
scoreboard = {"games": [g7, regular_live]}
f = tmp_path / "scoreboard_data.json"
f.write_text(json.dumps(scoreboard))
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(f))
data = json.loads(flask_client.get("/scoreboard").data)
pinned_names = [g["Home Team"] for g in data["pinned_games"]]
live_names = [g["Home Team"] for g in data["live_games"]]
assert "Kings" in pinned_names
assert "Kings" not in live_names
assert "Rangers" in live_names
class TestSeriesDetailRoute:
_SAMPLE_PAYLOAD = {
"round": 1,
"roundLabel": "1st-round",
"seriesLetter": "A",
"neededToWin": 4,
"length": 7,
"topSeedTeam": {
"id": 10,
"name": {"default": "Maple Leafs"},
"abbrev": "TOR",
"placeName": {"default": "Toronto"},
"record": "2-1",
"seriesWins": 2,
"divisionAbbrev": "A",
"seed": 1,
"logo": "https://example.com/tor.svg",
"darkLogo": "https://example.com/tor_dark.svg",
"conference": {"abbrev": "E"},
},
"bottomSeedTeam": {
"id": 9,
"name": {"default": "Senators"},
"abbrev": "OTT",
"placeName": {"default": "Ottawa"},
"record": "1-2",
"seriesWins": 1,
"divisionAbbrev": "A",
"seed": 4,
"logo": "https://example.com/ott.svg",
"darkLogo": "https://example.com/ott_dark.svg",
"conference": {"abbrev": "E"},
},
"games": [
{
"id": 1,
"gameNumber": 1,
"ifNecessary": False,
"venue": {"default": "Arena"},
"startTimeUTC": "2026-04-18T23:00:00Z",
"gameState": "OFF",
"periodDescriptor": {"number": 3, "periodType": "REG"},
"awayTeam": {"abbrev": "OTT", "score": 2, "commonName": {"default": "Senators"}},
"homeTeam": {"abbrev": "TOR", "score": 6, "commonName": {"default": "Maple Leafs"}},
"gameOutcome": {"lastPeriodType": "REG"},
},
{
"id": 2,
"gameNumber": 4,
"ifNecessary": False,
"venue": {"default": "Arena"},
"startTimeUTC": "2026-04-22T23:00:00Z",
"gameState": "FUT",
"periodDescriptor": {"number": 1, "periodType": "REG"},
"awayTeam": {"abbrev": "TOR", "commonName": {"default": "Maple Leafs"}},
"homeTeam": {"abbrev": "OTT", "commonName": {"default": "Senators"}},
},
],
}
def test_invalid_series_id_404(self, flask_client):
response = flask_client.get("/series/garbage")
assert response.status_code == 404
def test_valid_format_but_missing_cache_404(self, flask_client, monkeypatch):
import app.routes as routes
monkeypatch.setattr(routes, "fetch_series", lambda sid: None)
response = flask_client.get("/series/2026-A")
assert response.status_code == 404
def test_renders_with_cached_payload(self, flask_client, monkeypatch):
import app.routes as routes
monkeypatch.setattr(routes, "fetch_series", lambda sid: self._SAMPLE_PAYLOAD)
response = flask_client.get("/series/2026-A")
assert response.status_code == 200
body = response.data.decode("utf-8")
assert "Maple Leafs" in body
assert "Senators" in body
assert "Game 1" in body
assert "Game 4" in body
def test_letter_out_of_range_404(self, flask_client):
response = flask_client.get("/series/2026-Z")
assert response.status_code == 404
class TestBracketRoute:
_BRACKET = {
"bracketLogo": "http://example.com/bracket.png",
"series": [
{
"seriesLetter": "A",
"playoffRound": 1,
"topSeedWins": 2,
"bottomSeedWins": 1,
"topSeedRankAbbrev": "D1",
"bottomSeedRankAbbrev": "WC1",
"winningTeamId": None,
"topSeedTeam": {
"id": 10,
"abbrev": "TOR",
"name": {"default": "Toronto Maple Leafs"},
"commonName": {"default": "Maple Leafs"},
"darkLogo": "http://example.com/TOR.svg",
},
"bottomSeedTeam": {
"id": 9,
"abbrev": "OTT",
"name": {"default": "Ottawa Senators"},
"commonName": {"default": "Senators"},
"darkLogo": "http://example.com/OTT.svg",
},
}
],
}
def test_bracket_renders_with_cache(self, flask_client, monkeypatch):
import app.routes as routes
monkeypatch.setattr(routes, "get_bracket", lambda y: (self._BRACKET, 123))
monkeypatch.setattr(
routes,
"refresh_bracket",
lambda y=None: (_ for _ in ()).throw(AssertionError("should not refetch")),
)
response = flask_client.get("/bracket")
assert response.status_code == 200
body = response.data.decode("utf-8")
assert "Stanley Cup Playoffs" in body
assert "TOR" in body
assert "OTT" in body
def test_bracket_falls_back_to_fresh_fetch_when_cache_empty(
self, flask_client, monkeypatch
):
import app.routes as routes
monkeypatch.setattr(routes, "get_bracket", lambda y: (None, None))
called = {"n": 0}
def fake_refresh(year=None):
called["n"] += 1
return self._BRACKET
monkeypatch.setattr(routes, "refresh_bracket", fake_refresh)
response = flask_client.get("/bracket")
assert response.status_code == 200
assert called["n"] == 1
def test_bracket_returns_404_when_no_data(self, flask_client, monkeypatch):
import app.routes as routes
monkeypatch.setattr(routes, "get_bracket", lambda y: (None, None))
monkeypatch.setattr(routes, "refresh_bracket", lambda year=None: None)
response = flask_client.get("/bracket")
assert response.status_code == 404