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
+132
View File
@@ -0,0 +1,132 @@
"""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(l) for l in EAST_R1]
west_r1 = [slot(l) for l in WEST_R1]
east_r2 = [slot(l) for l in EAST_R2]
west_r2 = [slot(l) for l in WEST_R2]
east_cf = [slot(l) for l in EAST_CF]
west_cf = [slot(l) for l in WEST_CF]
cup = [slot(l) for l in CUP_FINAL]
return {
"year": year,
"fetched_at": fetched_at,
"bracket_logo": (bracket_payload or {}).get("bracketLogo"),
"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], "east": east_r1, "west": west_r1},
{"label": ROUND_LABELS[2], "east": east_r2, "west": west_r2},
{"label": ROUND_LABELS[3], "east": east_cf, "west": west_cf},
{"label": ROUND_LABELS[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
+28 -2
View File
@@ -4,6 +4,17 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app.config import DB_PATH
from app.playoff import (
is_pinned,
is_playoff_game,
is_playoff_ot,
ot_label,
series_badges,
series_blurb,
series_id,
series_state,
series_summary,
)
EASTERN = ZoneInfo("America/New_York")
@@ -100,15 +111,30 @@ def parse_games(scoreboard_data):
game, game["awayTeam"]["name"]["default"]
),
"Last Period Type": get_game_outcome(game, game_state),
"Is Playoff": is_playoff_game(game),
"Pinned": is_pinned(game),
"Playoff OT": is_playoff_ot(game),
"OT Label": ot_label(game.get("periodDescriptor", {}).get("number", 0))
if is_playoff_ot(game)
else "",
"Series Blurb": series_blurb(game) if is_playoff_game(game) else "",
"Series Summary": series_summary(game) if is_playoff_game(game) else "",
"Series Badges": series_badges(game) if is_playoff_game(game) else [],
"Series State": series_state(game.get("seriesStatus", {}))
if is_playoff_game(game)
else None,
"Series ID": series_id(game) if is_playoff_game(game) else None,
}
)
def _sort_key(g):
# Pinned playoff games (Game 7s) sort first within their state bucket.
pin_rank = 0 if g.get("Pinned") else 1
if g["Game State"] == "PRE":
# Earliest start first — ISO-8601 sorts correctly as a string
return (0, g["Start Time UTC"], 0)
return (pin_rank, 0, g["Start Time UTC"], 0)
# LIVE / FINAL — highest priority first
return (1, "", -g["Priority"])
return (pin_rank, 1, "", -g["Priority"])
return sorted(extracted_info, key=_sort_key)
+272
View File
@@ -0,0 +1,272 @@
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"
+217
View File
@@ -0,0 +1,217 @@
"""Playoff bracket + per-series schedule caching.
Single table `playoff_cache` keyed by arbitrary cache_key. Stale rows are
served on fetch failure up to 24h old, with callers free to check staleness
via the returned `fetched_at` timestamp.
"""
import json
import logging
import re
import sqlite3
import time
from datetime import date, datetime
from zoneinfo import ZoneInfo
import requests
from app.config import DB_PATH
logger = logging.getLogger(__name__)
EASTERN = ZoneInfo("America/New_York")
BRACKET_TTL = 3600 # refresh at this cadence via scheduler
SERIES_TTL = 300 # lazy cache for per-series schedule fetches
MAX_STALE_SECONDS = 86400 # 24h
SERIES_ID_RE = re.compile(r"^(20\d{2})-([A-P])$")
def _connect():
return sqlite3.connect(DB_PATH)
def create_cache_table(conn):
conn.execute(
"""
CREATE TABLE IF NOT EXISTS playoff_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
fetched_at INTEGER NOT NULL
)
"""
)
conn.commit()
def _put(cache_key, payload):
conn = _connect()
try:
create_cache_table(conn)
conn.execute(
"INSERT OR REPLACE INTO playoff_cache (cache_key, payload, fetched_at) "
"VALUES (?, ?, ?)",
(cache_key, json.dumps(payload), int(time.time())),
)
conn.commit()
finally:
conn.close()
def _get(cache_key):
"""Return (payload_dict, fetched_at_unix) or (None, None)."""
conn = _connect()
try:
create_cache_table(conn)
cur = conn.execute(
"SELECT payload, fetched_at FROM playoff_cache WHERE cache_key = ?",
(cache_key,),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return None, None
return json.loads(row[0]), row[1]
# ── Bracket ────────────────────────────────────────────────────────
def bracket_key(year):
return f"bracket:{year}"
def refresh_bracket(year=None):
"""Fetch /v1/playoff-bracket/{year} and store it. Returns payload or None."""
if year is None:
year = datetime.now(EASTERN).year
url = f"https://api-web.nhle.com/v1/playoff-bracket/{year}"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
_put(bracket_key(year), data)
_maybe_record_start_date(data)
return data
except requests.RequestException as e:
logger.warning("Failed to refresh playoff bracket for %s: %s", year, e)
return None
def get_bracket(year=None):
"""Return (bracket_payload, fetched_at) from cache. Never triggers a fetch."""
if year is None:
year = datetime.now(EASTERN).year
payload, fetched = _get(bracket_key(year))
return payload, fetched
# ── Per-series schedule ────────────────────────────────────────────
def series_key(season, letter):
return f"series:{season}:{letter.upper()}"
def parse_series_id(series_id):
"""Parse 'YYYY-L' into (season_str, letter). Returns None on invalid input."""
m = SERIES_ID_RE.match(series_id or "")
if not m:
return None
year, letter = m.group(1), m.group(2)
season = f"{int(year) - 1}{year}"
return season, letter
def fetch_series(series_id):
"""Fetch /v1/schedule/playoff-series/{season}/{letter}. 5-min cache.
Returns the raw API payload or None on both cache miss and fetch failure.
On failure we fall back to stale cache up to 24h old.
"""
parsed = parse_series_id(series_id)
if parsed is None:
return None
season, letter = parsed
key = series_key(season, letter)
payload, fetched = _get(key)
if payload is not None and fetched is not None:
if time.time() - fetched < SERIES_TTL:
return payload
url = f"https://api-web.nhle.com/v1/schedule/playoff-series/{season}/{letter.lower()}"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
_put(key, data)
return data
except requests.RequestException as e:
logger.warning("Failed to fetch series %s: %s", series_id, e)
if payload is not None and fetched is not None:
if time.time() - fetched < MAX_STALE_SECONDS:
return payload
return None
# ── Playoff start date (drives the "Day N" banner) ─────────────────
META_KEY = "meta:first_playoff_date"
def _maybe_record_start_date(bracket_payload):
"""Store the earliest scheduled round-1 series date as Day 1, once.
The bracket endpoint doesn't include individual games — only the series
list. So we record the current day as Day 1 on the first bracket fetch
where any series has nonzero wins OR is otherwise underway. For a perfect
Day-1 anchor we'd need the per-series schedule; the scoreboard-driven
fallback (see record_start_date_if_missing) handles that case.
"""
# No-op for now; the scoreboard-driven path is authoritative.
return None
def record_start_date_if_missing(scoreboard_games, now=None):
"""Called from routes on every /scoreboard hit.
If we haven't seen a playoff game yet and the current scoreboard contains
one, record today (Eastern) as Day 1. Idempotent after first write.
"""
existing, _ = _get(META_KEY)
if existing and existing.get("first_date"):
return existing["first_date"]
has_playoff = any(g.get("gameType") == 3 for g in scoreboard_games)
if not has_playoff:
return None
now = now or datetime.now(EASTERN)
today = now.date().isoformat()
_put(META_KEY, {"first_date": today, "recorded_at_utc": now.astimezone().isoformat()})
return today
def get_playoff_start_date():
payload, _ = _get(META_KEY)
if not payload or not payload.get("first_date"):
return None
try:
return date.fromisoformat(payload["first_date"])
except ValueError:
return None
def day_n(now=None):
"""Returns (day_n, day_total_estimate) or (None, None)."""
start = get_playoff_start_date()
if start is None:
return None, None
now = now or datetime.now(EASTERN)
today = now.date()
n = (today - start).days + 1
if n < 1:
return None, None
# Typical playoff length: ~60 days from first-round start through Cup final.
return n, 60
+55 -5
View File
@@ -1,10 +1,25 @@
import json
from flask import render_template, jsonify, send_from_directory
from flask import abort, render_template, jsonify, send_from_directory
from app import app
from app.config import SCOREBOARD_DATA_FILE
from app.games import parse_games
from app.playoff import today_meta
from app.bracket_view import build_bracket_view
from app.playoff_cache import (
day_n as compute_day_n,
fetch_series,
get_bracket,
parse_series_id,
record_start_date_if_missing,
refresh_bracket,
)
from app.series_view import build_series_view
from datetime import datetime
from zoneinfo import ZoneInfo
_EASTERN = ZoneInfo("America/New_York")
@app.route("/manifest.json")
@@ -45,20 +60,55 @@ def get_scoreboard():
)
if scoreboard_data:
raw_games = scoreboard_data.get("games", [])
record_start_date_if_missing(raw_games)
games = parse_games(scoreboard_data)
n, total = compute_day_n()
meta = today_meta(raw_games, day_n=n, day_total=total)
pinned = [g for g in games if g.get("Pinned")]
remaining = [g for g in games if not g.get("Pinned")]
return jsonify(
{
"meta": meta,
"pinned_games": pinned,
"live_games": [
g
for g in games
for g in remaining
if g["Game State"] == "LIVE" and not g["Intermission"]
],
"intermission_games": [
g for g in games if g["Game State"] == "LIVE" and g["Intermission"]
g
for g in remaining
if g["Game State"] == "LIVE" and g["Intermission"]
],
"pre_games": [g for g in games if g["Game State"] == "PRE"],
"final_games": [g for g in games if g["Game State"] == "FINAL"],
"pre_games": [g for g in remaining if g["Game State"] == "PRE"],
"final_games": [g for g in remaining if g["Game State"] == "FINAL"],
}
)
else:
return jsonify({"error": "Failed to retrieve scoreboard data"})
@app.route("/series/<series_id>")
def series_detail(series_id):
if parse_series_id(series_id) is None:
abort(404)
payload = fetch_series(series_id)
if payload is None:
abort(404)
view = build_series_view(series_id, payload)
return render_template("series.html", series=view)
@app.route("/bracket")
def bracket():
year = datetime.now(_EASTERN).year
payload, fetched_at = get_bracket(year)
if payload is None:
payload = refresh_bracket(year)
if payload is None:
abort(404)
view = build_bracket_view(year, payload, fetched_at=fetched_at)
return render_template("bracket.html", bracket=view)
+4
View File
@@ -4,6 +4,7 @@ import time
import schedule
from app.api import refresh_scores
from app.playoff_cache import refresh_bracket
from app.standings import refresh_standings
logger = logging.getLogger(__name__)
@@ -12,6 +13,9 @@ logger = logging.getLogger(__name__)
def start_scheduler():
schedule.every(600).seconds.do(refresh_standings)
schedule.every(10).seconds.do(refresh_scores)
schedule.every(3600).seconds.do(refresh_bracket)
# Populate the cache once at startup so the banner has data immediately.
refresh_bracket()
logger.info("Background scheduler started")
while True:
try:
+193
View File
@@ -0,0 +1,193 @@
"""Normalize NHL /v1/schedule/playoff-series payloads for the series template.
The API payload is verbose and nested; this module flattens it into a small
render-ready dict so series.html can stay simple.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from app.playoff import ROUND_LABELS, ot_label, series_state
EASTERN = ZoneInfo("America/New_York")
_STATE_LABELS = {
"FUT": "Scheduled",
"PRE": "Pregame",
"LIVE": "Live",
"CRIT": "Live",
"OFF": "Final",
"FINAL": "Final",
}
def build_series_view(series_id, payload):
"""Return a dict shaped for rendering in series.html.
`payload` is the raw JSON from /v1/schedule/playoff-series/{season}/{letter}.
"""
top = payload.get("topSeedTeam", {}) or {}
bot = payload.get("bottomSeedTeam", {}) or {}
games = payload.get("games", []) or []
top_wins = _to_int(top.get("seriesWins"))
bot_wins = _to_int(bot.get("seriesWins"))
needed = _to_int(payload.get("neededToWin"), default=4)
state = series_state(
{
"round": _to_int(payload.get("round"), default=1),
"topSeedWins": top_wins,
"bottomSeedWins": bot_wins,
"topSeedTeamAbbrev": top.get("abbrev"),
"bottomSeedTeamAbbrev": bot.get("abbrev"),
}
)
leader_team = None
trailer_team = None
if state["leader"] == "top":
leader_team, trailer_team = _team_view(top), _team_view(bot)
elif state["leader"] == "bottom":
leader_team, trailer_team = _team_view(bot), _team_view(top)
normalized_games = [_game_view(g) for g in games]
played = [g for g in normalized_games if g["state_group"] == "completed"]
upcoming = [g for g in normalized_games if g["state_group"] != "completed"]
next_game = upcoming[0] if upcoming else None
round_num = _to_int(payload.get("round"), default=1)
return {
"series_id": series_id,
"round": round_num,
"round_label": payload.get("roundLabel")
or ROUND_LABELS.get(round_num, f"Round {round_num}"),
"series_letter": payload.get("seriesLetter"),
"needed_to_win": needed,
"length": _to_int(payload.get("length"), default=7),
"top": _team_view(top),
"bottom": _team_view(bot),
"top_wins": top_wins,
"bottom_wins": bot_wins,
"leader": leader_team,
"trailer": trailer_team,
"state": state,
"headline": _headline(state, leader_team, trailer_team, top_wins, bot_wins),
"games": normalized_games,
"played_games": played,
"next_game": next_game,
"series_logo": payload.get("seriesLogo"),
}
def _team_view(team):
if not team:
return None
name = (team.get("name") or {}).get("default") or team.get("abbrev", "")
place = (team.get("placeName") or {}).get("default") or ""
return {
"id": team.get("id"),
"name": name,
"place": place,
"full": f"{place} {name}".strip() if place else name,
"abbrev": team.get("abbrev"),
"logo": team.get("darkLogo") or team.get("logo"),
"record": team.get("record"),
"seed": team.get("seed"),
"series_wins": _to_int(team.get("seriesWins")),
"division": team.get("divisionAbbrev"),
"conference": (team.get("conference") or {}).get("abbrev"),
}
def _game_view(game):
gs = game.get("gameState", "")
state_label = _STATE_LABELS.get(gs, gs.title() if gs else "Scheduled")
completed = gs in ("OFF", "FINAL")
live = gs in ("LIVE", "CRIT")
home = game.get("homeTeam", {}) or {}
away = game.get("awayTeam", {}) or {}
start_local, start_date = _format_start(game.get("startTimeUTC"))
last_period = (game.get("gameOutcome") or {}).get("lastPeriodType") or ""
period_num = _to_int((game.get("periodDescriptor") or {}).get("number"))
ended_in_ot = completed and last_period == "OT"
ended_multi_ot = completed and period_num >= 4 and last_period == "OT"
winner_abbrev = None
if completed:
home_score = _to_int(home.get("score"))
away_score = _to_int(away.get("score"))
if home_score > away_score:
winner_abbrev = home.get("abbrev")
elif away_score > home_score:
winner_abbrev = away.get("abbrev")
return {
"id": game.get("id"),
"game_number": _to_int(game.get("gameNumber"), default=1),
"if_necessary": bool(game.get("ifNecessary")),
"venue": (game.get("venue") or {}).get("default", ""),
"start_utc": game.get("startTimeUTC"),
"start_local": start_local,
"start_date": start_date,
"state": gs,
"state_label": state_label,
"state_group": "completed" if completed else ("live" if live else "upcoming"),
"live": live,
"period_number": period_num,
"period_ot_label": ot_label(period_num) if live and period_num >= 4 else "",
"ended_in_ot": ended_in_ot,
"ended_in_multi_ot": ended_multi_ot,
"home": {
"abbrev": home.get("abbrev"),
"name": (home.get("commonName") or {}).get("default"),
"place": (home.get("placeName") or {}).get("default"),
"score": _to_int(home.get("score")) if completed or live else None,
},
"away": {
"abbrev": away.get("abbrev"),
"name": (away.get("commonName") or {}).get("default"),
"place": (away.get("placeName") or {}).get("default"),
"score": _to_int(away.get("score")) if completed or live else None,
},
"winner_abbrev": winner_abbrev,
}
def _headline(state, leader, trailer, top_wins, bot_wins):
if state["is_game7"]:
return "Win-or-go-home \u2014 Game 7 tonight."
if state["is_clincher"] and leader:
return f"{leader['full']} can close it out in Game {state['game_number']}."
if state["is_pivotal"]:
return f"Series tied 2\u20112 \u2014 pivotal Game {state['game_number']}."
if state["is_opener"]:
return "Series opener."
if leader and trailer:
return (
f"{leader['full']} leads {state['hi']}\u2011{state['lo']} "
f"\u2014 Game {state['game_number']} next."
)
return f"Series even {top_wins}\u2011{bot_wins}."
def _format_start(start_utc):
if not start_utc:
return "", ""
try:
dt = datetime.fromisoformat(start_utc.replace("Z", "+00:00")).astimezone(
EASTERN
)
except ValueError:
return "", ""
return dt.strftime("%-I:%M %p ET"), dt.strftime("%a %b %-d")
def _to_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
+212 -17
View File
@@ -9,7 +9,10 @@ async function fetchScoreboardData() {
}
function updateScoreboard(data) {
applyMeta(data.meta);
const sections = [
{ sectionId: 'pinned-section', gridId: 'pinned-games-section', games: data.pinned_games, render: renderPinnedGame },
{ sectionId: 'live-section', gridId: 'live-games-section', games: data.live_games, render: renderLiveGame },
{ sectionId: 'intermission-section', gridId: 'intermission-games-section', games: data.intermission_games, render: renderLiveGame },
{ sectionId: 'pre-section', gridId: 'pre-games-section', games: data.pre_games, render: renderPreGame },
@@ -32,23 +35,96 @@ function updateScoreboard(data) {
}
updateGauges();
maybeNotifyOT(data);
}
// ── Banner / Meta ─────────────────────────────────────
function applyMeta(meta) {
const banner = document.getElementById('playoff-banner');
if (!meta || !meta.playoff_mode) {
document.body.classList.remove('playoff-mode');
banner.classList.add('hidden');
banner.setAttribute('aria-hidden', 'true');
return;
}
document.body.classList.add('playoff-mode');
banner.classList.remove('hidden');
banner.setAttribute('aria-hidden', 'false');
setText(banner.querySelector('.banner-year'), meta.year ? String(meta.year) : '');
setText(banner.querySelector('.meta-round'), meta.round_label || '');
const dayEl = banner.querySelector('.meta-day');
if (meta.day_n != null) {
const total = meta.day_total ? ` of ~${meta.day_total}` : '';
setText(dayEl, `Day ${meta.day_n}${total}`);
dayEl.classList.remove('hidden');
} else {
dayEl.classList.add('hidden');
}
const seriesEl = banner.querySelector('.meta-series');
if (meta.series_active) {
const word = meta.series_active === 1 ? 'series' : 'series';
setText(seriesEl, `${meta.series_active} ${word} in action`);
seriesEl.classList.remove('hidden');
} else {
seriesEl.classList.add('hidden');
}
const elimEl = banner.querySelector('.meta-elim');
if (meta.elimination_count > 0) {
const n = meta.elimination_count;
setText(elimEl, `${n} elimination game${n === 1 ? '' : 's'}`);
elimEl.classList.remove('hidden');
} else {
elimEl.classList.add('hidden');
}
const g7El = banner.querySelector('.meta-game7');
if (meta.game7_count > 0) {
const n = meta.game7_count;
setText(g7El, `${n} Game 7${n === 1 ? '' : 's'}`);
g7El.classList.remove('hidden');
} else {
g7El.classList.add('hidden');
}
}
function setText(el, text) {
if (el) el.textContent = text;
}
// ── Renderers ────────────────────────────────────────
function renderLiveGame(game) {
function renderPinnedGame(game) {
if (game['Game State'] === 'PRE') return renderPreGame(game, { pinned: true });
if (game['Game State'] === 'FINAL') return renderFinalGame(game, { pinned: true });
return renderLiveGame(game, { pinned: true });
}
function renderLiveGame(game, opts = {}) {
const intermission = game['Intermission'];
const period = game['Period'];
const time = game['Time Remaining'];
const running = game['Time Running'];
const isPlayoff = game['Is Playoff'];
const playoffOT = game['Playoff OT'];
const periodLabel = intermission
const periodText = playoffOT
? (game['OT Label'] || 'OT')
: ordinalPeriod(period);
const periodBadge = intermission
? `<span class="badge">${intermissionLabel(period)}</span>`
: `<span class="badge badge-live">${ordinalPeriod(period)}</span>`;
: playoffOT
? `<span class="badge badge-sudden-death">${periodText} &middot; SUDDEN DEATH</span>`
: `<span class="badge badge-live">${periodText}</span>`;
const dot = running ? `<span class="live-dot"></span>` : '';
// Tick the clock locally when the clock is running or during intermission
const shouldTick = running || intermission;
const rawSeconds = timeToSeconds(time);
const clockAttrs = shouldTick
@@ -63,11 +139,17 @@ function renderLiveGame(game) {
</div>
</div>` : '';
return `
<div class="game-box ${intermission ? 'game-box-intermission' : 'game-box-live'}" data-game-key="${game['Away Team']}|${game['Home Team']}">
const stateClass = intermission ? 'game-box-intermission' : 'game-box-live';
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
const otClass = playoffOT ? ' game-box-sudden-death' : '';
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
return wrapSeriesLink(game, `
<div class="game-box ${stateClass}${playoffClass}${otClass}${pinnedClass}" data-game-key="${gameKey(game)}">
${playoffContext(game)}
<div class="card-header">
<div class="badges">
${periodLabel}
${periodBadge}
<span class="badge" ${clockAttrs}>${time}</span>
${ppBadge(game)}
</div>
@@ -76,12 +158,17 @@ function renderLiveGame(game) {
${teamRow(game, 'Away', 'live')}
${teamRow(game, 'Home', 'live')}
${hype}
</div>`;
${seriesBlurb(game)}
</div>`);
}
function renderPreGame(game) {
return `
<div class="game-box">
function renderPreGame(game, opts = {}) {
const isPlayoff = game['Is Playoff'];
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
return wrapSeriesLink(game, `
<div class="game-box${playoffClass}${pinnedClass}" data-game-key="${gameKey(game)}">
${playoffContext(game)}
<div class="card-header">
<div class="badges">
<span class="badge">${game['Start Time']}</span>
@@ -89,14 +176,19 @@ function renderPreGame(game) {
</div>
${teamRow(game, 'Away', 'pre')}
${teamRow(game, 'Home', 'pre')}
</div>`;
${seriesBlurb(game)}
</div>`);
}
function renderFinalGame(game) {
function renderFinalGame(game, opts = {}) {
const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' };
const label = labels[game['Last Period Type']] ?? 'Final';
return `
<div class="game-box">
const isPlayoff = game['Is Playoff'];
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
return wrapSeriesLink(game, `
<div class="game-box${playoffClass}${pinnedClass}" data-game-key="${gameKey(game)}">
${playoffContext(game)}
<div class="card-header">
<div class="badges">
<span class="badge badge-muted">${label}</span>
@@ -104,7 +196,42 @@ function renderFinalGame(game) {
</div>
${teamRow(game, 'Away', 'final')}
${teamRow(game, 'Home', 'final')}
</div>`;
${seriesBlurb(game)}
</div>`);
}
function wrapSeriesLink(game, html) {
const sid = game['Series ID'];
if (!sid) return html;
return `<a class="series-link" href="/series/${sid}" aria-label="Series detail">${html}</a>`;
}
// ── Playoff context (badges row + series summary) ─────
function playoffContext(game) {
if (!game['Is Playoff']) return '';
const badges = (game['Series Badges'] || [])
.map(b => `<span class="badge ${badgeClassFor(b)}">${b}</span>`)
.join('');
const summary = game['Series Summary']
? `<span class="series-summary">${game['Series Summary']}</span>`
: '';
if (!badges && !summary) return '';
return `<div class="playoff-context">${badges}${summary}</div>`;
}
function badgeClassFor(label) {
if (label === 'GAME 7') return 'badge-game7';
if (label === 'CLINCHER') return 'badge-clincher';
if (label === 'PIVOTAL') return 'badge-pivotal';
if (label === 'CUP FINAL') return 'badge-round badge-cup';
if (label === 'CONF FINAL')return 'badge-round badge-conf';
return 'badge-round';
}
function seriesBlurb(game) {
if (!game['Is Playoff'] || !game['Series Blurb']) return '';
return `<div class="series-blurb">${game['Series Blurb']}</div>`;
}
// ── Team Row ─────────────────────────────────────────
@@ -148,6 +275,10 @@ function ppBadge(game) {
return `<span class="badge badge-pp">PP ${team} <span ${attrs}>${timeStr}</span></span>`;
}
function gameKey(game) {
return `${game['Away Team']}|${game['Home Team']}`;
}
// ── Gauge ────────────────────────────────────────────
function updateGauges() {
@@ -197,7 +328,6 @@ function restoreClocks(grid, snapshot) {
if (!prior) return;
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
if (!badge) return;
// Only restore if we're outside the final sync window
if (prior.current > CLOCK_SYNC_THRESHOLD) {
badge.dataset.seconds = prior.current;
badge.dataset.receivedAt = prior.ts;
@@ -226,6 +356,70 @@ function intermissionLabel(period) {
return ['1st Int', '2nd Int', '3rd Int'][period - 1] ?? 'Int';
}
// ── OT Notifications (Phase 1: client-only) ──────────
const OT_SEEN_KEY = 'nhl_ot_seen_v1';
function seenOTKeys() {
try { return new Set(JSON.parse(sessionStorage.getItem(OT_SEEN_KEY) || '[]')); }
catch { return new Set(); }
}
function persistSeenOT(set) {
sessionStorage.setItem(OT_SEEN_KEY, JSON.stringify([...set]));
}
function maybeNotifyOT(data) {
if (!('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
const seen = seenOTKeys();
const candidates = [...(data.pinned_games || []), ...(data.live_games || [])];
let changed = false;
for (const g of candidates) {
if (!g['Playoff OT']) continue;
const k = `${gameKey(g)}|${g['Period']}`;
if (seen.has(k)) continue;
seen.add(k);
changed = true;
try {
new Notification('Playoff OT \u2014 Sudden Death', {
body: `${g['Away Team']} @ ${g['Home Team']}`,
silent: false,
tag: k,
});
} catch (e) {
console.warn('Notification failed:', e);
}
}
if (changed) persistSeenOT(seen);
}
function wireOTButton() {
const btn = document.querySelector('.banner-notify');
if (!btn) return;
if (!('Notification' in window)) {
btn.disabled = true;
btn.title = 'Notifications not supported in this browser';
return;
}
reflectOTPermission(btn);
btn.addEventListener('click', async () => {
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
reflectOTPermission(btn);
});
}
function reflectOTPermission(btn) {
const state = Notification.permission;
btn.dataset.perm = state;
if (state === 'granted') btn.title = 'OT alerts enabled';
else if (state === 'denied') btn.title = 'OT alerts blocked in browser settings';
else btn.title = 'Enable OT alerts';
}
// ── Init ─────────────────────────────────────────────
function autoRefresh() {
@@ -234,6 +428,7 @@ function autoRefresh() {
}
window.addEventListener('load', () => {
wireOTButton();
autoRefresh();
setInterval(tickClocks, 1000);
if ('serviceWorker' in navigator) {
+790
View File
@@ -12,6 +12,19 @@
--gap: 1rem;
--radius: 12px;
--card-w: 290px;
/* Cup theme palette — only referenced when body.playoff-mode is set */
--cup-gold-1: #d4af37;
--cup-gold-2: #f5d76e;
--cup-gold-dim: #8a6d1a;
--cup-silver-1: #c0c8d0;
--cup-silver-2: #e8ecef;
--cup-silver-dim: #6b7580;
--ice-1: #0a1628;
--ice-2: #162844;
--ice-accent: #4fc3f7;
--gold-gradient: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-2) 50%, var(--cup-gold-1));
--banner-bg: linear-gradient(135deg, #0a1628 0%, #162844 55%, #1a1a2e 100%);
}
*, *::before, *::after {
@@ -352,3 +365,780 @@ main {
flex-direction: column;
}
}
/* ── Playoff Banner ─────────────────────────────── */
.playoff-banner {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: var(--banner-bg);
border-bottom: 2px solid transparent;
border-image: var(--gold-gradient) 1;
position: relative;
}
.playoff-banner.hidden {
display: none;
}
.banner-trophy {
width: 36px;
height: 44px;
flex-shrink: 0;
filter: drop-shadow(0 0 6px rgba(212, 175, 55, 0.25));
}
.banner-text {
flex: 1;
min-width: 0;
}
.banner-title {
font-size: 0.95rem;
font-weight: 800;
letter-spacing: 0.12em;
background: var(--gold-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
line-height: 1.2;
}
.banner-year {
color: var(--cup-silver-2);
-webkit-text-fill-color: var(--cup-silver-2);
font-weight: 700;
margin-left: 0.35rem;
}
.banner-meta {
margin-top: 0.2rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.7rem;
font-size: 0.75rem;
color: var(--cup-silver-1);
letter-spacing: 0.03em;
}
.banner-meta > span.hidden { display: none; }
.banner-meta > span:not(.hidden) + span:not(.hidden)::before {
content: "\00b7";
color: var(--cup-gold-dim);
margin-right: 0.7rem;
}
.meta-elim, .meta-game7 {
color: var(--cup-gold-2);
font-weight: 700;
}
.banner-notify {
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--cup-gold-dim);
color: var(--cup-gold-2);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.45rem 0.7rem;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 120ms ease, border-color 120ms ease;
}
.banner-notify:hover {
background: rgba(212, 175, 55, 0.18);
border-color: var(--cup-gold-1);
}
.banner-notify[data-perm="granted"] {
background: rgba(212, 175, 55, 0.2);
border-color: var(--cup-gold-1);
color: var(--cup-gold-2);
}
.banner-notify[data-perm="denied"] {
opacity: 0.5;
cursor: not-allowed;
}
@media (min-width: 900px) {
.playoff-banner {
padding: 1.125rem 2rem;
gap: 1.25rem;
}
.banner-trophy { width: 44px; height: 54px; }
.banner-title { font-size: 1.15rem; }
.banner-meta { font-size: 0.85rem; }
}
@media (max-width: 640px) {
.playoff-banner {
flex-wrap: wrap;
padding: 0.75rem 1rem;
}
.banner-notify {
order: 3;
margin-left: auto;
}
}
/* ── Playoff Game Cards ─────────────────────────── */
.playoff-mode .section-heading-gold {
background: var(--gold-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
letter-spacing: 0.18em;
font-weight: 800;
}
.game-box-playoff {
border-top-width: 3px;
border-image: var(--gold-gradient) 1;
border-image-slice: 1;
}
/* Gold top stripe wins over the green-accent live stripe on playoff games */
.game-box-playoff.game-box-live,
.game-box-playoff.game-box-intermission {
border-image: var(--gold-gradient) 1;
}
.game-box-pinned {
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.35),
0 6px 20px rgba(212, 175, 55, 0.1);
}
.game-box-sudden-death {
border-top-width: 3px;
animation: pulse-gold 2.2s ease-in-out infinite;
}
@keyframes pulse-gold {
0%, 100% {
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.35),
0 0 10px rgba(212, 175, 55, 0.25);
}
50% {
box-shadow: 0 0 0 1px rgba(245, 215, 110, 0.75),
0 0 22px rgba(245, 215, 110, 0.45);
}
}
/* Playoff context row above the period badges */
.playoff-context {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.6rem;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--card-border);
}
.series-summary {
font-size: 0.72rem;
color: var(--cup-silver-1);
font-weight: 600;
letter-spacing: 0.02em;
}
/* Playoff stake badges */
.badge-round {
background: rgba(212, 175, 55, 0.1);
color: var(--cup-gold-2);
border: 1px solid rgba(212, 175, 55, 0.35);
}
.badge-conf {
background: rgba(79, 195, 247, 0.12);
color: var(--ice-accent);
border-color: rgba(79, 195, 247, 0.4);
}
.badge-cup {
background: linear-gradient(90deg, rgba(212, 175, 55, 0.25), rgba(245, 215, 110, 0.25));
color: #1a1200;
border: 1px solid var(--cup-gold-1);
font-weight: 800;
}
.badge-game7 {
background: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-1));
color: #1a1200;
border: 1px solid var(--cup-gold-2);
font-weight: 800;
letter-spacing: 0.1em;
}
.badge-clincher {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.45);
}
.badge-pivotal {
background: rgba(79, 195, 247, 0.12);
color: var(--ice-accent);
border: 1px solid rgba(79, 195, 247, 0.4);
}
.badge-sudden-death {
background: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-1));
color: #1a1200;
border: 1px solid var(--cup-gold-2);
font-weight: 800;
letter-spacing: 0.08em;
}
.series-blurb {
margin-top: 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid var(--card-border);
font-size: 0.72rem;
color: var(--cup-silver-1);
letter-spacing: 0.01em;
font-style: italic;
text-align: center;
}
/* In playoff mode, retint the hype meter to silver → gold */
.playoff-mode .hype-label {
color: var(--cup-silver-dim);
}
/* Override the red live dot with gold for playoff-mode bodies */
.playoff-mode .game-box-playoff .live-dot {
background: var(--cup-gold-1);
box-shadow: 0 0 6px rgba(245, 215, 110, 0.7);
}
@media (min-width: 900px) {
.series-summary { font-size: 0.82rem; }
.series-blurb { font-size: 0.82rem; }
}
@media (min-width: 1400px) {
.series-summary { font-size: 0.9rem; }
.series-blurb { font-size: 0.9rem; }
}
.series-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem 0.25rem;
}
.series-header .header-title {
font-size: 1.3rem;
}
/* Clickable playoff card wrapper */
.series-link {
display: contents;
color: inherit;
text-decoration: none;
}
.series-link .game-box {
cursor: pointer;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.series-link:hover .game-box {
border-color: var(--cup-gold-1);
transform: translateY(-1px);
}
.series-link:focus-visible .game-box {
outline: 2px solid var(--cup-gold-2);
outline-offset: 2px;
}
/* ── Series detail page (/series/<id>) ──────────── */
.header-link {
text-decoration: none;
color: var(--cup-silver-2);
}
.series-main {
padding: 1rem 1.25rem 3rem;
max-width: 1000px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.series-hero {
background: var(--banner-bg);
border: 1px solid var(--cup-gold-dim);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
text-align: center;
}
.series-hero-eyebrow {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
}
.series-teams {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
width: 100%;
}
.series-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 0.5rem;
border-radius: 10px;
border: 1px solid transparent;
}
.series-team-leader {
border-color: var(--cup-gold-dim);
background: rgba(212, 175, 55, 0.06);
}
.series-team-logo {
width: 72px;
height: 72px;
object-fit: contain;
}
.series-team-name {
font-weight: 600;
font-size: 1.05rem;
color: var(--cup-silver-2);
}
.series-team-meta {
font-size: 0.78rem;
color: var(--cup-silver-dim);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.series-team-wins {
font-size: 2.5rem;
font-weight: 700;
color: var(--cup-gold-2);
line-height: 1;
margin-top: 0.25rem;
}
.series-versus {
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: center;
}
.series-versus-label {
font-size: 0.7rem;
letter-spacing: 0.15em;
color: var(--cup-silver-dim);
}
.series-versus-score {
font-size: 1.4rem;
font-weight: 600;
color: var(--cup-gold-2);
}
.series-versus-best {
font-size: 0.75rem;
color: var(--cup-silver-dim);
}
.series-headline {
font-size: 1rem;
color: var(--cup-silver-1);
max-width: 46ch;
}
.series-next-card {
background: var(--card);
border: 1px solid var(--cup-gold-dim);
border-radius: 10px;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: center;
margin-top: 0.6rem;
}
.series-next-matchup {
display: flex;
gap: 0.6rem;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
}
.series-next-team {
color: var(--cup-silver-2);
}
.series-next-at {
color: var(--cup-silver-dim);
font-size: 0.9rem;
}
.series-next-meta {
font-size: 0.85rem;
color: var(--cup-silver-1);
text-align: center;
}
.series-games {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.6rem;
}
.series-game {
display: grid;
grid-template-columns: 90px 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 10px;
}
.series-game-live,
.series-game-completed {
border-color: var(--cup-gold-dim);
}
.series-game-col-number {
font-weight: 600;
color: var(--cup-silver-1);
letter-spacing: 0.03em;
font-size: 0.85rem;
}
.series-game-col-matchup {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.series-game-team {
display: flex;
justify-content: space-between;
gap: 0.6rem;
font-size: 0.95rem;
}
.series-game-abbrev {
font-weight: 600;
color: var(--cup-silver-2);
}
.series-game-score {
color: var(--cup-silver-1);
font-variant-numeric: tabular-nums;
min-width: 1.6em;
text-align: right;
}
.series-game-winner {
color: var(--cup-gold-2);
font-weight: 700;
}
.series-game-state {
font-size: 0.8rem;
color: var(--cup-silver-dim);
text-align: right;
}
@media (max-width: 600px) {
.series-teams {
grid-template-columns: 1fr;
}
.series-versus {
order: 3;
}
.series-team-logo {
width: 56px;
height: 56px;
}
.series-game {
grid-template-columns: 70px 1fr;
}
.series-game-col-state {
grid-column: 1 / -1;
text-align: left;
}
.series-game-state {
text-align: left;
}
}
/* ── Bracket page (/bracket) ─────────────────────── */
.bracket-main {
padding: 1rem 1.25rem 3rem;
max-width: 1600px;
margin: 0 auto;
}
.bracket-hero {
text-align: center;
margin-bottom: 1.5rem;
}
.bracket-title {
font-size: 1.6rem;
font-weight: 700;
color: var(--cup-silver-2);
letter-spacing: 0.05em;
background: var(--gold-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.bracket-subtitle {
color: var(--cup-silver-dim);
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* Desktop grid layout */
.bracket-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.75rem;
align-items: stretch;
}
.bracket-col {
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 0.6rem;
}
.bracket-col-heading {
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cup-silver-dim);
text-align: center;
margin-bottom: 0.25rem;
}
.bracket-cup-heading {
color: var(--cup-gold-2);
}
.bracket-col-cup {
justify-content: center;
}
.bracket-matchup {
display: flex;
flex-direction: column;
gap: 2px;
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 0.45rem 0.55rem;
text-decoration: none;
color: inherit;
transition: border-color 0.12s ease, transform 0.12s ease;
}
.bracket-matchup:hover {
border-color: var(--cup-gold-1);
transform: translateY(-1px);
}
.bracket-matchup:focus-visible {
outline: 2px solid var(--cup-gold-2);
outline-offset: 2px;
}
.bracket-matchup-active {
border-color: var(--cup-gold-dim);
}
.bracket-matchup-complete {
opacity: 0.75;
}
.bracket-matchup-empty {
background: transparent;
border-style: dashed;
}
.bracket-col-cup .bracket-matchup {
border-color: var(--cup-gold-dim);
background: linear-gradient(135deg, #162844 0%, #1a1a2e 100%);
padding: 0.7rem 0.6rem;
}
.bracket-col-cup .bracket-matchup:hover {
border-color: var(--cup-gold-2);
}
.bracket-team {
display: grid;
grid-template-columns: 22px 1fr auto auto;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 0.85rem;
}
.bracket-team-logo {
width: 22px;
height: 22px;
object-fit: contain;
}
.bracket-team-abbrev {
font-weight: 600;
color: var(--cup-silver-2);
}
.bracket-team-seed {
font-size: 0.7rem;
color: var(--cup-silver-dim);
letter-spacing: 0.03em;
}
.bracket-team-wins {
font-variant-numeric: tabular-nums;
min-width: 1.2em;
text-align: right;
color: var(--cup-silver-1);
font-weight: 600;
}
.bracket-team-winner {
color: var(--cup-gold-2);
}
.bracket-team-winner .bracket-team-abbrev,
.bracket-team-winner .bracket-team-wins {
color: var(--cup-gold-2);
}
.bracket-team-placeholder {
color: var(--cup-silver-dim);
text-align: center;
padding: 0.3rem 0;
font-size: 0.8rem;
grid-template-columns: 1fr;
}
/* Mobile accordion — hidden on desktop */
.bracket-accordion {
display: none;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.bracket-round {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 10px;
overflow: hidden;
}
.bracket-round-summary {
padding: 0.8rem 1rem;
cursor: pointer;
font-weight: 600;
color: var(--cup-gold-2);
letter-spacing: 0.03em;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.bracket-round-summary::after {
content: "+";
color: var(--cup-silver-dim);
font-size: 1.2rem;
}
.bracket-round[open] .bracket-round-summary::after {
content: "";
}
.bracket-round-body {
padding: 0 0.8rem 0.8rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.bracket-round-half-heading {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cup-silver-dim);
margin-bottom: 0.3rem;
}
.bracket-round-half {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* Switch layout at narrow widths */
@media (max-width: 900px) {
.bracket-grid { display: none; }
.bracket-accordion { display: flex; }
}
/* Banner bracket link (both pages) */
.banner-bracket-link {
background: transparent;
border: 1px solid var(--cup-gold-dim);
color: var(--cup-gold-2);
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.78rem;
letter-spacing: 0.05em;
text-decoration: none;
transition: border-color 0.12s ease, color 0.12s ease;
white-space: nowrap;
}
.banner-bracket-link:hover {
border-color: var(--cup-gold-1);
color: var(--cup-gold-2);
}
+15 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'nhl-scoreboard-v1';
const CACHE = 'nhl-scoreboard-v3';
const PRECACHE = [
'/',
'/static/styles.css',
@@ -33,6 +33,20 @@ self.addEventListener('fetch', event => {
return;
}
// Network-first for bracket + series detail pages; fall back to cache offline
if (pathname === '/bracket' || pathname.startsWith('/series/')) {
event.respondWith(
fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(event.request, clone));
}
return response;
}).catch(() => caches.match(event.request))
);
return;
}
// Cache-first for everything else (static assets, shell)
event.respondWith(
caches.match(event.request).then(cached => {
+21
View File
@@ -0,0 +1,21 @@
{% if m.empty %}
<div class="bracket-matchup bracket-matchup-empty">
<div class="bracket-team bracket-team-placeholder">TBD</div>
<div class="bracket-team bracket-team-placeholder">TBD</div>
</div>
{% else %}
<a class="bracket-matchup bracket-matchup-{{ m.state }}" href="/series/{{ m.series_id }}">
<div class="bracket-team {% if m.winner_abbrev == m.top.abbrev %}bracket-team-winner{% endif %}">
{% if m.top.logo %}<img class="bracket-team-logo" src="{{ m.top.logo }}" alt="{{ m.top.abbrev }}">{% endif %}
<span class="bracket-team-abbrev">{{ m.top.abbrev }}</span>
{% if m.top.seed %}<span class="bracket-team-seed">{{ m.top.seed }}</span>{% endif %}
<span class="bracket-team-wins">{{ m.top_wins }}</span>
</div>
<div class="bracket-team {% if m.winner_abbrev == m.bottom.abbrev %}bracket-team-winner{% endif %}">
{% if m.bottom.logo %}<img class="bracket-team-logo" src="{{ m.bottom.logo }}" alt="{{ m.bottom.abbrev }}">{% endif %}
<span class="bracket-team-abbrev">{{ m.bottom.abbrev }}</span>
{% if m.bottom.seed %}<span class="bracket-team-seed">{{ m.bottom.seed }}</span>{% endif %}
<span class="bracket-team-wins">{{ m.bottom_wins }}</span>
</div>
</a>
{% endif %}
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ bracket.year }} Stanley Cup Bracket</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png">
<link rel="stylesheet" type="text/css" href="/static/styles.css">
</head>
<body class="playoff-mode bracket-mode">
<header>
<a class="header-title header-link" href="/">&larr; NHL Scoreboard</a>
</header>
<main class="bracket-main">
<section class="bracket-hero">
<h1 class="bracket-title">{{ bracket.year }} Stanley Cup Playoffs</h1>
<div class="bracket-subtitle">The road to 16 wins</div>
</section>
{# Desktop: 7-column grid (East R1 | R2 | CF | Cup | CF | R2 | R1 West) #}
<section class="bracket-grid" aria-label="Full playoff bracket">
<div class="bracket-col bracket-col-r1 bracket-col-east">
<h2 class="bracket-col-heading">First Round</h2>
{% for m in bracket.east_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-r2 bracket-col-east">
<h2 class="bracket-col-heading">Second Round</h2>
{% for m in bracket.east_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-cf bracket-col-east">
<h2 class="bracket-col-heading">East Final</h2>
{% for m in bracket.east_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-cup">
<h2 class="bracket-col-heading bracket-cup-heading">Cup Final</h2>
{% for m in bracket.cup %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-cf bracket-col-west">
<h2 class="bracket-col-heading">West Final</h2>
{% for m in bracket.west_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-r2 bracket-col-west">
<h2 class="bracket-col-heading">Second Round</h2>
{% for m in bracket.west_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-r1 bracket-col-west">
<h2 class="bracket-col-heading">First Round</h2>
{% for m in bracket.west_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
</section>
{# Mobile: round-by-round accordion, round 1 open by default #}
<section class="bracket-accordion" aria-label="Playoff bracket by round">
{% for rnd in bracket.rounds %}
<details class="bracket-round" {% if loop.first %}open{% endif %}>
<summary class="bracket-round-summary">{{ rnd.label }}</summary>
<div class="bracket-round-body">
{% if rnd.get('east') %}
<div class="bracket-round-half">
<h3 class="bracket-round-half-heading">Eastern</h3>
{% for m in rnd.east %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
{% endif %}
{% if rnd.get('west') %}
<div class="bracket-round-half">
<h3 class="bracket-round-half-heading">Western</h3>
{% for m in rnd.west %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
{% endif %}
{% if rnd.get('cup') %}
<div class="bracket-round-half">
{% for m in rnd.cup %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endfor %}
</section>
</main>
</body>
</html>
+35
View File
@@ -16,7 +16,42 @@
<header>
<span class="header-title">NHL Scoreboard</span>
</header>
<section id="playoff-banner" class="playoff-banner hidden" aria-hidden="true">
<svg class="banner-trophy" viewBox="0 0 32 40" aria-hidden="true">
<defs>
<linearGradient id="cup-gold" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#f5d76e"/>
<stop offset="60%" stop-color="#d4af37"/>
<stop offset="100%" stop-color="#8a6d1a"/>
</linearGradient>
</defs>
<path fill="url(#cup-gold)" d="M6 2h20v4c0 5-2 9-5 11l-1 5h-8l-1-5C8 15 6 11 6 6V2zm4 20h12v3H10v-3zm-2 4h16v3H8v-3zm1 4h14v6H9v-6z"/>
<rect x="11" y="9" width="10" height="2" fill="#0a1628" opacity="0.35"/>
<rect x="11" y="13" width="10" height="1.5" fill="#0a1628" opacity="0.35"/>
</svg>
<div class="banner-text">
<div class="banner-title">
STANLEY CUP PLAYOFFS
<span class="banner-year"></span>
</div>
<div class="banner-meta">
<span class="meta-round"></span>
<span class="meta-day hidden"></span>
<span class="meta-series"></span>
<span class="meta-elim hidden"></span>
<span class="meta-game7 hidden"></span>
</div>
</div>
<a class="banner-bracket-link" href="/bracket">Bracket</a>
<button class="banner-notify" type="button" title="Notify me on playoff OT" aria-label="Enable OT notifications">
<span class="bell-label">OT alerts</span>
</button>
</section>
<main>
<section id="pinned-section" class="section pinned-section hidden">
<h2 class="section-heading section-heading-gold">Spotlight &middot; Game 7</h2>
<div id="pinned-games-section" class="games-grid"></div>
</section>
<section id="live-section" class="section hidden">
<h2 class="section-heading">Live</h2>
<div id="live-games-section" class="games-grid"></div>
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ series.top.abbrev }} vs {{ series.bottom.abbrev }} &middot; {{ series.round_label }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png">
<link rel="stylesheet" type="text/css" href="/static/styles.css">
</head>
<body class="playoff-mode series-mode">
<header class="series-header">
<a class="header-title header-link" href="/">&larr; NHL Scoreboard</a>
<a class="banner-bracket-link" href="/bracket">Bracket</a>
</header>
<main class="series-main">
<section class="series-hero">
<div class="series-hero-eyebrow">
<span class="badge badge-round">{{ series.round_label|upper }}</span>
{% if series.state.is_game7 %}<span class="badge badge-game7">GAME 7</span>
{% elif series.state.is_clincher %}<span class="badge badge-clincher">CLINCHER</span>
{% elif series.state.is_pivotal %}<span class="badge badge-pivotal">PIVOTAL</span>{% endif %}
</div>
<div class="series-teams">
<div class="series-team {% if series.leader and series.leader.abbrev == series.top.abbrev %}series-team-leader{% endif %}">
{% if series.top.logo %}<img class="series-team-logo" src="{{ series.top.logo }}" alt="{{ series.top.abbrev }}">{% endif %}
<div class="series-team-name">{{ series.top.full }}</div>
<div class="series-team-meta">
{% if series.top.seed %}Seed {{ series.top.seed }}{% endif %}
{% if series.top.division %} &middot; {{ series.top.division }}{% endif %}
</div>
<div class="series-team-wins">{{ series.top_wins }}</div>
</div>
<div class="series-versus">
<span class="series-versus-label">SERIES</span>
<span class="series-versus-score">{{ series.top_wins }} &ndash; {{ series.bottom_wins }}</span>
<span class="series-versus-best">Best of {{ series.length }}</span>
</div>
<div class="series-team {% if series.leader and series.leader.abbrev == series.bottom.abbrev %}series-team-leader{% endif %}">
{% if series.bottom.logo %}<img class="series-team-logo" src="{{ series.bottom.logo }}" alt="{{ series.bottom.abbrev }}">{% endif %}
<div class="series-team-name">{{ series.bottom.full }}</div>
<div class="series-team-meta">
{% if series.bottom.seed %}Seed {{ series.bottom.seed }}{% endif %}
{% if series.bottom.division %} &middot; {{ series.bottom.division }}{% endif %}
</div>
<div class="series-team-wins">{{ series.bottom_wins }}</div>
</div>
</div>
<p class="series-headline">{{ series.headline }}</p>
</section>
{% if series.next_game %}
<section class="series-next">
<h2 class="section-heading section-heading-gold">Next up &middot; Game {{ series.next_game.game_number }}</h2>
<div class="series-next-card">
<div class="series-next-matchup">
<span class="series-next-team">{{ series.next_game.away.abbrev }}</span>
<span class="series-next-at">@</span>
<span class="series-next-team">{{ series.next_game.home.abbrev }}</span>
</div>
<div class="series-next-meta">
{% if series.next_game.start_date %}{{ series.next_game.start_date }}{% endif %}
{% if series.next_game.start_local %} &middot; {{ series.next_game.start_local }}{% endif %}
{% if series.next_game.venue %} &middot; {{ series.next_game.venue }}{% endif %}
{% if series.next_game.if_necessary %} &middot; <em>if necessary</em>{% endif %}
</div>
</div>
</section>
{% endif %}
<section class="series-history">
<h2 class="section-heading">Games</h2>
<ol class="series-games">
{% for game in series.games %}
<li class="series-game series-game-{{ game.state_group }}">
<div class="series-game-col-number">Game {{ game.game_number }}{% if game.if_necessary and game.state_group != 'completed' %}*{% endif %}</div>
<div class="series-game-col-matchup">
<div class="series-game-team">
<span class="series-game-abbrev">{{ game.away.abbrev }}</span>
<span class="series-game-score {% if game.winner_abbrev == game.away.abbrev %}series-game-winner{% endif %}">
{% if game.away.score is not none %}{{ game.away.score }}{% else %}&mdash;{% endif %}
</span>
</div>
<div class="series-game-team">
<span class="series-game-abbrev">{{ game.home.abbrev }}</span>
<span class="series-game-score {% if game.winner_abbrev == game.home.abbrev %}series-game-winner{% endif %}">
{% if game.home.score is not none %}{{ game.home.score }}{% else %}&mdash;{% endif %}
</span>
</div>
</div>
<div class="series-game-col-state">
{% if game.live %}
<span class="badge badge-live">LIVE</span>
{% if game.period_ot_label %}<span class="badge badge-sudden-death">{{ game.period_ot_label }}</span>{% endif %}
{% elif game.state_group == 'completed' %}
<span class="series-game-state">{{ game.state_label }}{% if game.ended_in_ot %} &middot; {{ 'OT' if not game.ended_in_multi_ot else 'Multi-OT' }}{% endif %}</span>
{% else %}
<span class="series-game-state">
{% if game.start_date %}{{ game.start_date }}{% endif %}
{% if game.start_local %} &middot; {{ game.start_local }}{% endif %}
</span>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</section>
</main>
</body>
</html>
+49
View File
@@ -19,6 +19,8 @@ def make_game(
game_type=2,
situation=None,
series_status=None,
home_abbrev="TOR",
away_abbrev="BOS",
):
clock = {
"timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}",
@@ -33,6 +35,7 @@ def make_game(
"clock": clock,
"homeTeam": {
"name": {"default": home_name},
"abbrev": home_abbrev,
"score": home_score,
"sog": 15,
"logo": "https://example.com/home.png",
@@ -40,6 +43,7 @@ def make_game(
},
"awayTeam": {
"name": {"default": away_name},
"abbrev": away_abbrev,
"score": away_score,
"sog": 12,
"logo": "https://example.com/away.png",
@@ -52,6 +56,49 @@ def make_game(
}
def make_playoff_game(
top_wins=0,
bottom_wins=0,
round_num=1,
series_letter="A",
top_abbrev="TOR",
bottom_abbrev="BOS",
top_is_home=True,
game_state="LIVE",
**kwargs,
):
"""Convenience wrapper around make_game for playoff fixtures.
`top_is_home` controls which side of the matchup hosts this game, so tests
for the blurb copy (leader vs. trailer names) don't have to juggle raw dicts.
"""
series_status = {
"round": round_num,
"topSeedWins": top_wins,
"bottomSeedWins": bottom_wins,
"seriesLetter": series_letter,
"topSeedTeamAbbrev": top_abbrev,
"bottomSeedTeamAbbrev": bottom_abbrev,
}
if top_is_home:
home_abbrev, away_abbrev = top_abbrev, bottom_abbrev
home_name, away_name = "Top Seeds", "Bottom Seeds"
else:
home_abbrev, away_abbrev = bottom_abbrev, top_abbrev
home_name, away_name = "Bottom Seeds", "Top Seeds"
return make_game(
game_state=game_state,
game_type=3,
series_status=series_status,
home_abbrev=kwargs.pop("home_abbrev", home_abbrev),
away_abbrev=kwargs.pop("away_abbrev", away_abbrev),
home_name=kwargs.pop("home_name", home_name),
away_name=kwargs.pop("away_name", away_name),
**kwargs,
)
LIVE_GAME = make_game()
PRE_GAME = make_game(
game_state="FUT", home_score=0, away_score=0, period=0, seconds_remaining=1200
@@ -89,9 +136,11 @@ def flask_client(tmp_path, monkeypatch):
# Patch module-level path constants so no reloads are needed
import app.routes as routes
import app.games as games
import app.playoff_cache as playoff_cache
monkeypatch.setattr(routes, "SCOREBOARD_DATA_FILE", str(scoreboard_file))
monkeypatch.setattr(games, "DB_PATH", str(db_path))
monkeypatch.setattr(playoff_cache, "DB_PATH", str(db_path))
from app import app as flask_app
+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]
+102 -1
View File
@@ -1,5 +1,5 @@
import app.games
from tests.conftest import make_game
from tests.conftest import make_game, make_playoff_game
from app.games import (
_get_man_advantage,
calculate_game_importance,
@@ -745,6 +745,107 @@ class TestGetComebackBonus:
assert result == 70 # 50*1.0 + 20
class TestPlayoffEnrichment:
_FULL_STANDINGS = {
"league_sequence": 16,
"league_l10_sequence": 16,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 40,
"wildcard_sequence": 16,
}
def test_regular_season_game_has_empty_playoff_fields(self, mocker):
mocker.patch(
"app.games.get_team_standings", return_value=self._FULL_STANDINGS
)
result = parse_games({"games": [make_game()]})
g = result[0]
assert g["Is Playoff"] is False
assert g["Pinned"] is False
assert g["Playoff OT"] is False
assert g["Series Blurb"] == ""
assert g["Series Badges"] == []
def test_playoff_game_gets_series_fields(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = make_playoff_game(top_wins=2, bottom_wins=1, round_num=1)
result = parse_games({"games": [game]})
g = result[0]
assert g["Is Playoff"] is True
assert g["Pinned"] is False
assert "Game 4" in g["Series Blurb"]
assert "R1" in g["Series Badges"]
def test_game7_is_pinned(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = make_playoff_game(top_wins=3, bottom_wins=3)
result = parse_games({"games": [game]})
assert result[0]["Pinned"] is True
assert "GAME 7" in result[0]["Series Badges"]
def test_pinned_game_sorts_first(self, mocker):
mocker.patch(
"app.games.get_team_standings", return_value=self._FULL_STANDINGS
)
# Low-priority Game 7 PRE should sort above a high-priority non-playoff LIVE
g7_pre = make_playoff_game(
top_wins=3,
bottom_wins=3,
game_state="FUT",
period=0,
seconds_remaining=1200,
start_time_utc="2026-04-20T23:00:00Z",
home_name="Kings",
away_name="Oilers",
)
hype_live = make_game(
game_state="LIVE",
home_name="Rangers",
away_name="Devils",
home_score=2,
away_score=2,
period=3,
seconds_remaining=60,
)
result = parse_games({"games": [hype_live, g7_pre]})
assert result[0]["Home Team"] == "Kings"
assert result[0]["Pinned"] is True
def test_playoff_ot_flagged(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={"league_sequence": 0, "league_l10_sequence": 0},
)
game = make_playoff_game(
top_wins=1,
bottom_wins=1,
period=4,
seconds_remaining=600,
game_state="LIVE",
)
result = parse_games({"games": [game]})
assert result[0]["Playoff OT"] is True
assert result[0]["OT Label"] == "OT"
def test_regular_season_ot_not_flagged_as_playoff_ot(self, mocker):
mocker.patch(
"app.games.get_team_standings", return_value=self._FULL_STANDINGS
)
game = make_game(
game_state="LIVE", period=4, seconds_remaining=180, game_type=2
)
result = parse_games({"games": [game]})
assert result[0]["Playoff OT"] is False
assert result[0]["OT Label"] == ""
class TestCalculateGameImportance:
def _standings(
self,
+205
View File
@@ -0,0 +1,205 @@
import json
import time
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from app import playoff_cache
EASTERN = ZoneInfo("America/New_York")
@pytest.fixture
def tmp_db(tmp_path, monkeypatch):
db_path = tmp_path / "playoff_cache.db"
monkeypatch.setattr(playoff_cache, "DB_PATH", str(db_path))
return str(db_path)
class _Resp:
def __init__(self, payload, status=200):
self._payload = payload
self.status_code = status
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
import requests
raise requests.HTTPError(f"HTTP {self.status_code}")
class TestParseSeriesId:
def test_valid(self):
assert playoff_cache.parse_series_id("2026-A") == ("20252026", "A")
def test_lowercase_rejected(self):
assert playoff_cache.parse_series_id("2026-a") is None
def test_invalid_letter(self):
assert playoff_cache.parse_series_id("2026-Q") is None
def test_malformed(self):
assert playoff_cache.parse_series_id("abc") is None
def test_none(self):
assert playoff_cache.parse_series_id(None) is None
class TestBracket:
def test_refresh_success_stores_payload(self, tmp_db, monkeypatch):
payload = {"series": [{"seriesLetter": "A"}], "year": 2026}
monkeypatch.setattr(
"app.playoff_cache.requests.get",
lambda *a, **kw: _Resp(payload),
)
result = playoff_cache.refresh_bracket(2026)
assert result == payload
cached, fetched = playoff_cache.get_bracket(2026)
assert cached == payload
assert fetched is not None
def test_refresh_failure_returns_none(self, tmp_db, monkeypatch):
import requests
def raiser(*a, **kw):
raise requests.ConnectionError("boom")
monkeypatch.setattr("app.playoff_cache.requests.get", raiser)
assert playoff_cache.refresh_bracket(2026) is None
def test_get_bracket_empty(self, tmp_db):
payload, fetched = playoff_cache.get_bracket(2026)
assert payload is None and fetched is None
class TestFetchSeries:
def test_success_stores_and_returns(self, tmp_db, monkeypatch):
payload = {"seriesLetter": "A", "games": []}
monkeypatch.setattr(
"app.playoff_cache.requests.get",
lambda *a, **kw: _Resp(payload),
)
result = playoff_cache.fetch_series("2026-A")
assert result == payload
def test_invalid_id_returns_none(self, tmp_db):
assert playoff_cache.fetch_series("garbage") is None
def test_cache_hit_skips_network(self, tmp_db, monkeypatch):
payload_cached = {"from": "cache"}
playoff_cache._put(playoff_cache.series_key("20252026", "A"), payload_cached)
def should_not_be_called(*a, **kw):
raise AssertionError("network should not be called within TTL")
monkeypatch.setattr("app.playoff_cache.requests.get", should_not_be_called)
assert playoff_cache.fetch_series("2026-A") == payload_cached
def test_stale_cache_falls_back_on_failure(self, tmp_db, monkeypatch):
import requests
key = playoff_cache.series_key("20252026", "A")
playoff_cache._put(key, {"from": "stale"})
# Force the cached row to look older than the TTL but within MAX_STALE
with playoff_cache._connect() as conn:
old_ts = int(time.time()) - (playoff_cache.SERIES_TTL + 60)
conn.execute(
"UPDATE playoff_cache SET fetched_at = ? WHERE cache_key = ?",
(old_ts, key),
)
conn.commit()
def raiser(*a, **kw):
raise requests.ConnectionError("network gone")
monkeypatch.setattr("app.playoff_cache.requests.get", raiser)
assert playoff_cache.fetch_series("2026-A") == {"from": "stale"}
def test_ancient_stale_cache_returns_none(self, tmp_db, monkeypatch):
import requests
key = playoff_cache.series_key("20252026", "A")
playoff_cache._put(key, {"from": "ancient"})
with playoff_cache._connect() as conn:
ancient_ts = int(time.time()) - (playoff_cache.MAX_STALE_SECONDS + 60)
conn.execute(
"UPDATE playoff_cache SET fetched_at = ? WHERE cache_key = ?",
(ancient_ts, key),
)
conn.commit()
monkeypatch.setattr(
"app.playoff_cache.requests.get",
lambda *a, **kw: (_ for _ in ()).throw(requests.ConnectionError("x")),
)
assert playoff_cache.fetch_series("2026-A") is None
class TestRecordStartDate:
def test_no_playoff_games_no_write(self, tmp_db):
result = playoff_cache.record_start_date_if_missing([{"gameType": 2}])
assert result is None
assert playoff_cache.get_playoff_start_date() is None
def test_records_on_first_playoff_sighting(self, tmp_db):
now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
result = playoff_cache.record_start_date_if_missing(
[{"gameType": 3}], now=now
)
assert result == "2026-04-18"
assert playoff_cache.get_playoff_start_date().isoformat() == "2026-04-18"
def test_idempotent_after_first_write(self, tmp_db):
first_now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
second_now = datetime(2026, 4, 25, 20, 0, tzinfo=EASTERN)
playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=first_now)
# Second call should not overwrite
result = playoff_cache.record_start_date_if_missing(
[{"gameType": 3}], now=second_now
)
assert result == "2026-04-18"
class TestDayN:
def test_no_start_date(self, tmp_db):
assert playoff_cache.day_n() == (None, None)
def test_day_one(self, tmp_db):
now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=now)
n, total = playoff_cache.day_n(now=now)
assert n == 1
assert total == 60
def test_day_five(self, tmp_db):
start = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
later = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN)
playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=start)
n, _ = playoff_cache.day_n(now=later)
assert n == 5
class TestSchema:
def test_table_created_on_first_use(self, tmp_db):
# Accessing _get triggers create_cache_table
payload, fetched = playoff_cache._get("missing")
assert payload is None
conn = playoff_cache._connect()
try:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name='playoff_cache'"
)
assert cur.fetchone() is not None
finally:
conn.close()
def test_put_upserts(self, tmp_db):
playoff_cache._put("k", {"v": 1})
playoff_cache._put("k", {"v": 2})
cached, _ = playoff_cache._get("k")
assert cached == {"v": 2}
+271
View File
@@ -0,0 +1,271 @@
import pytest
from app.playoff import (
is_pinned,
is_playoff_game,
is_playoff_ot,
ot_label,
series_badges,
series_blurb,
series_state,
series_summary,
today_meta,
)
from tests.conftest import make_game, make_playoff_game
class TestSeriesState:
def test_empty_returns_defaults(self):
state = series_state({})
assert state["is_opener"] is True
assert state["game_number"] == 1
assert state["round"] == 1
assert state["leader"] is None
@pytest.mark.parametrize(
"top,bot,expected_game",
[(0, 0, 1), (1, 0, 2), (1, 1, 3), (2, 1, 4), (2, 2, 5), (3, 2, 6), (3, 3, 7)],
)
def test_game_number_computation(self, top, bot, expected_game):
state = series_state({"round": 1, "topSeedWins": top, "bottomSeedWins": bot})
assert state["game_number"] == expected_game
def test_game7_predicate(self):
state = series_state({"round": 1, "topSeedWins": 3, "bottomSeedWins": 3})
assert state["is_game7"] is True
assert state["is_clincher"] is False
assert state["is_pivotal"] is False
def test_clincher_predicate(self):
state = series_state({"round": 1, "topSeedWins": 3, "bottomSeedWins": 1})
assert state["is_clincher"] is True
assert state["is_elimination"] is True
assert state["is_game7"] is False
def test_pivotal_predicate(self):
state = series_state({"round": 2, "topSeedWins": 2, "bottomSeedWins": 2})
assert state["is_pivotal"] is True
assert state["is_game7"] is False
assert state["is_clincher"] is False
def test_opener_predicate(self):
state = series_state({"round": 1, "topSeedWins": 0, "bottomSeedWins": 0})
assert state["is_opener"] is True
def test_leader_top(self):
state = series_state({"round": 1, "topSeedWins": 2, "bottomSeedWins": 1})
assert state["leader"] == "top"
assert state["hi"] == 2 and state["lo"] == 1
def test_leader_bottom(self):
state = series_state({"round": 1, "topSeedWins": 1, "bottomSeedWins": 2})
assert state["leader"] == "bottom"
def test_no_leader_when_tied(self):
state = series_state({"round": 1, "topSeedWins": 1, "bottomSeedWins": 1})
assert state["leader"] is None
class TestSeriesBlurb:
def test_opener_blurb(self):
game = make_playoff_game(top_wins=0, bottom_wins=0)
assert series_blurb(game) == "Series opener."
def test_game7_blurb(self):
game = make_playoff_game(top_wins=3, bottom_wins=3)
assert series_blurb(game) == "Win-or-go-home \u2014 Game 7."
def test_clincher_blurb_names_leader(self):
game = make_playoff_game(
top_wins=3,
bottom_wins=1,
top_abbrev="TOR",
bottom_abbrev="BOS",
top_is_home=True,
)
blurb = series_blurb(game)
assert "Top Seeds" in blurb
assert "close it out" in blurb
assert "Game 5" in blurb
def test_pivotal_blurb(self):
game = make_playoff_game(top_wins=2, bottom_wins=2)
assert "pivotal" in series_blurb(game).lower()
assert "Game 5" in series_blurb(game)
def test_leader_trailer_blurb(self):
game = make_playoff_game(top_wins=2, bottom_wins=1)
blurb = series_blurb(game)
assert "leads" in blurb
assert "Game 4" in blurb
def test_tied_mid_series_blurb(self):
game = make_playoff_game(top_wins=1, bottom_wins=1)
blurb = series_blurb(game)
assert "1" in blurb
assert "Game 3" in blurb
class TestSeriesBadges:
def test_round_1_always_first(self):
game = make_playoff_game(round_num=1, top_wins=1, bottom_wins=0)
assert series_badges(game)[0] == "R1"
def test_cup_final_label(self):
game = make_playoff_game(round_num=4, top_wins=0, bottom_wins=0)
assert series_badges(game)[0] == "CUP FINAL"
def test_conf_final_label(self):
game = make_playoff_game(round_num=3, top_wins=0, bottom_wins=0)
assert series_badges(game)[0] == "CONF FINAL"
def test_game7_badge(self):
game = make_playoff_game(top_wins=3, bottom_wins=3)
assert "GAME 7" in series_badges(game)
def test_clincher_badge(self):
game = make_playoff_game(top_wins=3, bottom_wins=1)
assert "CLINCHER" in series_badges(game)
def test_pivotal_badge(self):
game = make_playoff_game(top_wins=2, bottom_wins=2)
assert "PIVOTAL" in series_badges(game)
def test_opener_has_no_stake_badge(self):
badges = series_badges(make_playoff_game(top_wins=0, bottom_wins=0))
assert badges == ["R1"]
class TestSeriesSummary:
def test_opener_summary(self):
game = make_playoff_game(top_wins=0, bottom_wins=0, round_num=1)
assert "Round 1" in series_summary(game)
def test_leader_summary(self):
game = make_playoff_game(top_wins=2, bottom_wins=1)
assert "leads" in series_summary(game)
def test_tied_mid_series_summary(self):
game = make_playoff_game(top_wins=1, bottom_wins=1)
assert "tied" in series_summary(game).lower()
class TestIsPinned:
def test_game7_live_is_pinned(self):
game = make_playoff_game(top_wins=3, bottom_wins=3, game_state="LIVE")
assert is_pinned(game) is True
def test_game7_pre_is_pinned(self):
game = make_playoff_game(top_wins=3, bottom_wins=3, game_state="FUT")
assert is_pinned(game) is True
def test_game7_final_not_pinned(self):
game = make_playoff_game(top_wins=3, bottom_wins=3, game_state="OFF")
assert is_pinned(game) is False
def test_non_game7_not_pinned(self):
game = make_playoff_game(top_wins=2, bottom_wins=1)
assert is_pinned(game) is False
def test_regular_season_not_pinned(self):
game = make_game() # game_type=2, no series
assert is_pinned(game) is False
class TestIsPlayoffOt:
def test_playoff_period_4_live(self):
game = make_playoff_game(period=4, game_state="LIVE")
assert is_playoff_ot(game) is True
def test_playoff_period_5_live(self):
game = make_playoff_game(period=5, game_state="LIVE")
assert is_playoff_ot(game) is True
def test_playoff_period_3_not_ot(self):
game = make_playoff_game(period=3, game_state="LIVE")
assert is_playoff_ot(game) is False
def test_regular_season_ot_not_playoff_ot(self):
game = make_game(period=4, game_state="LIVE", game_type=2)
assert is_playoff_ot(game) is False
def test_crit_state_counts_as_live(self):
game = make_playoff_game(period=4, game_state="CRIT")
assert is_playoff_ot(game) is True
def test_final_state_not_playoff_ot(self):
game = make_playoff_game(period=4, game_state="OFF")
assert is_playoff_ot(game) is False
class TestOtLabel:
def test_period_4_is_ot(self):
assert ot_label(4) == "OT"
def test_period_5_is_2ot(self):
assert ot_label(5) == "2OT"
def test_period_6_is_3ot(self):
assert ot_label(6) == "3OT"
def test_pre_ot_returns_empty(self):
assert ot_label(3) == ""
assert ot_label(0) == ""
class TestIsPlayoffGame:
def test_playoff_raw_shape(self):
assert is_playoff_game(make_playoff_game()) is True
def test_regular_raw_shape(self):
assert is_playoff_game(make_game(game_type=2)) is False
def test_parsed_shape(self):
# parse_games produces {"Game Type": 3} — is_playoff_game should handle both
assert is_playoff_game({"Game Type": 3}) is True
assert is_playoff_game({"Game Type": 2}) is False
class TestTodayMeta:
def test_no_playoff_games_off_mode(self):
meta = today_meta([make_game(game_type=2)])
assert meta["playoff_mode"] is False
assert meta["round_label"] is None
def test_playoff_games_on_mode(self):
games = [make_playoff_game(series_letter="A"), make_playoff_game(series_letter="B")]
meta = today_meta(games)
assert meta["playoff_mode"] is True
assert meta["series_active"] == 2
assert meta["round_label"] == "First Round"
def test_counts_game7(self):
games = [
make_playoff_game(top_wins=3, bottom_wins=3, series_letter="A"),
make_playoff_game(top_wins=1, bottom_wins=0, series_letter="B"),
]
meta = today_meta(games)
assert meta["game7_count"] == 1
assert meta["elimination_count"] == 0
def test_counts_elimination_games(self):
games = [
make_playoff_game(top_wins=3, bottom_wins=1, series_letter="A"),
make_playoff_game(top_wins=3, bottom_wins=2, series_letter="B"),
]
meta = today_meta(games)
assert meta["elimination_count"] == 2
assert meta["game7_count"] == 0
def test_round_label_reflects_highest_active_round(self):
games = [
make_playoff_game(round_num=1, series_letter="A"),
make_playoff_game(round_num=2, series_letter="I"),
]
meta = today_meta(games)
assert meta["round_label"] == "Second Round"
def test_cup_final_label(self):
games = [make_playoff_game(round_num=4, series_letter="P")]
meta = today_meta(games)
assert meta["round_label"] == "Stanley Cup Final"
+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
+25
View File
@@ -6,6 +6,7 @@ from app.scheduler import start_scheduler
class TestStartScheduler:
def test_registers_standings_refresh_every_600_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
@@ -16,6 +17,7 @@ class TestStartScheduler:
def test_registers_score_refresh_every_10_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
@@ -24,8 +26,30 @@ class TestStartScheduler:
intervals = [call[0][0] for call in mock_schedule.every.call_args_list]
assert 10 in intervals
def test_registers_bracket_refresh_every_3600_seconds(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
start_scheduler()
intervals = [call[0][0] for call in mock_schedule.every.call_args_list]
assert 3600 in intervals
def test_invokes_bracket_refresh_eagerly_at_startup(self, mocker):
mocker.patch("app.scheduler.schedule")
eager = mocker.patch("app.scheduler.refresh_bracket")
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
with pytest.raises(StopIteration):
start_scheduler()
assert eager.called
def test_runs_pending_on_each_tick(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
call_count = {"n": 0}
def sleep_twice(_):
@@ -42,6 +66,7 @@ class TestStartScheduler:
def test_continues_after_exception_in_run_pending(self, mocker):
mock_schedule = mocker.patch("app.scheduler.schedule")
mocker.patch("app.scheduler.refresh_bracket")
call_count = {"n": 0}
def raise_then_stop(_):