feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
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:
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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,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:
|
||||
|
||||
@@ -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
@@ -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} · 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) {
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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="/">← 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>
|
||||
@@ -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 · 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>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ series.top.abbrev }} vs {{ series.bottom.abbrev }} · {{ 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="/">← 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 %} · {{ 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 }} – {{ 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 %} · {{ 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 · 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 %} · {{ series.next_game.start_local }}{% endif %}
|
||||
{% if series.next_game.venue %} · {{ series.next_game.venue }}{% endif %}
|
||||
{% if series.next_game.if_necessary %} · <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 %}—{% 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 %}—{% 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 %} · {{ '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 %} · {{ game.start_local }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(_):
|
||||
|
||||
Reference in New Issue
Block a user