Compare commits
34 Commits
108b77ed39
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f99738d2e4 | |||
| 4f5871d119 | |||
| babd199eb3 | |||
| fa3d315db0 | |||
| 1bc013e32b | |||
| 26678b164b | |||
| 2da60e27ae | |||
| 58b27ddd20 | |||
| b8819167f5 | |||
| fac1a0ecbc | |||
| c95bea879d | |||
| 4e5fab654d | |||
| c7ba334bb9 | |||
| dd1d9fe42d | |||
| 7d1649d278 | |||
| aaa0899506 | |||
| 9b8b671e24 | |||
| 303db46cc3 | |||
| dc3bfd13b3 | |||
| e0a1c033cf | |||
| b5ab318e05 | |||
| 9eb8a8534a | |||
| e908139323 | |||
| 13bb90b52b | |||
| 0f1c558493 | |||
| f1e11a2dc4 | |||
| 5cdcb2a319 | |||
| 8468655bcf | |||
| 64b2e4b5e1 | |||
| 4ea6b87326 | |||
| a88e2edef0 | |||
| 930247b32f | |||
| ebe770fecd | |||
| e0db8f0859 |
@@ -45,7 +45,7 @@ jobs:
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v
|
||||
run: pytest --cov --cov-report=term-missing
|
||||
|
||||
build-push:
|
||||
name: Build & Push
|
||||
|
||||
@@ -6,4 +6,6 @@ nhl_standings.db
|
||||
**/__pycache__
|
||||
.venv/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.claude/
|
||||
|
||||
+71
-1
@@ -1,5 +1,10 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from flask import Flask
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request
|
||||
|
||||
from app.config import LOG_LEVEL
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -10,4 +15,69 @@ logging.basicConfig(
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
# ── Static asset versioning ────────────────────────────────────────
|
||||
# Each /static/<file> reference gets a ?v=<hash> query string so we can serve
|
||||
# it with `Cache-Control: immutable` and still bust the cache when bytes change.
|
||||
_FALLBACK_TOKEN = hashlib.sha1(str(time.time()).encode()).hexdigest()[:8]
|
||||
_static_hashes: dict[str, str] = {}
|
||||
|
||||
|
||||
def _hash_file(path: Path) -> str:
|
||||
h = hashlib.sha1()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()[:8]
|
||||
|
||||
|
||||
def static_hash(filename: str) -> str:
|
||||
if filename in _static_hashes:
|
||||
return _static_hashes[filename]
|
||||
path = Path(app.static_folder) / filename
|
||||
try:
|
||||
token = _hash_file(path)
|
||||
except OSError:
|
||||
logging.getLogger(__name__).warning(
|
||||
"static_hash: cannot hash %s, using fallback token", filename
|
||||
)
|
||||
return _FALLBACK_TOKEN
|
||||
_static_hashes[filename] = token
|
||||
return token
|
||||
|
||||
|
||||
def static_v(filename: str) -> str:
|
||||
return f"/static/{filename}?v={static_hash(filename)}"
|
||||
|
||||
|
||||
_static_dir = Path(app.static_folder)
|
||||
if _static_dir.is_dir():
|
||||
for _path in sorted(_static_dir.iterdir()):
|
||||
if _path.is_file():
|
||||
try:
|
||||
_static_hashes[_path.name] = _hash_file(_path)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
APP_VERSION = (
|
||||
hashlib.sha1(
|
||||
"|".join(f"{n}:{h}" for n, h in sorted(_static_hashes.items())).encode()
|
||||
).hexdigest()[:8]
|
||||
if _static_hashes
|
||||
else _FALLBACK_TOKEN
|
||||
)
|
||||
|
||||
app.jinja_env.globals["static_v"] = static_v
|
||||
|
||||
|
||||
@app.after_request
|
||||
def _add_cache_headers(response):
|
||||
if response.mimetype == "text/html":
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return response
|
||||
if request.path.startswith("/static/") and request.args.get("v"):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
from app import routes # noqa: E402, F401
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Normalize NHL /v1/playoff-bracket payloads for the bracket template.
|
||||
|
||||
The NHL bracket uses stable series letters:
|
||||
A,B,C,D = Round 1 East E,F,G,H = Round 1 West
|
||||
I,J = Round 2 East K,L = Round 2 West
|
||||
M = Conf Final East N = Conf Final West
|
||||
O = Stanley Cup Final
|
||||
"""
|
||||
|
||||
from app.playoff import ROUND_LABELS
|
||||
|
||||
EAST_R1 = ["A", "B", "C", "D"]
|
||||
WEST_R1 = ["E", "F", "G", "H"]
|
||||
EAST_R2 = ["I", "J"]
|
||||
WEST_R2 = ["K", "L"]
|
||||
EAST_CF = ["M"]
|
||||
WEST_CF = ["N"]
|
||||
CUP_FINAL = ["O"]
|
||||
|
||||
|
||||
def build_bracket_view(year, bracket_payload, fetched_at=None):
|
||||
"""Shape the raw bracket API payload for bracket.html.
|
||||
|
||||
Returns a dict of rounds grouped by conference, plus a flat `matchups` list
|
||||
keyed by letter for the mobile accordion. Missing letters render as empty
|
||||
placeholder slots so the grid stays visually complete before upsets decide.
|
||||
"""
|
||||
series_by_letter = {}
|
||||
for s in (bracket_payload or {}).get("series", []):
|
||||
letter = s.get("seriesLetter")
|
||||
if letter:
|
||||
series_by_letter[letter] = s
|
||||
|
||||
def slot(letter):
|
||||
return _matchup(year, letter, series_by_letter.get(letter))
|
||||
|
||||
east_r1 = [slot(ltr) for ltr in EAST_R1]
|
||||
west_r1 = [slot(ltr) for ltr in WEST_R1]
|
||||
east_r2 = [slot(ltr) for ltr in EAST_R2]
|
||||
west_r2 = [slot(ltr) for ltr in WEST_R2]
|
||||
east_cf = [slot(ltr) for ltr in EAST_CF]
|
||||
west_cf = [slot(ltr) for ltr in WEST_CF]
|
||||
cup = [slot(ltr) for ltr in CUP_FINAL]
|
||||
|
||||
all_rounds = [
|
||||
(1, east_r1 + west_r1),
|
||||
(2, east_r2 + west_r2),
|
||||
(3, east_cf + west_cf),
|
||||
(4, cup),
|
||||
]
|
||||
current_round = None
|
||||
for r, matchups in all_rounds:
|
||||
if any(m["state"] == "active" for m in matchups):
|
||||
current_round = r
|
||||
break
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"fetched_at": fetched_at,
|
||||
"bracket_logo": (bracket_payload or {}).get("bracketLogo"),
|
||||
"current_round": current_round,
|
||||
"east_r1": east_r1,
|
||||
"west_r1": west_r1,
|
||||
"east_r2": east_r2,
|
||||
"west_r2": west_r2,
|
||||
"east_cf": east_cf,
|
||||
"west_cf": west_cf,
|
||||
"cup": cup,
|
||||
"rounds": [
|
||||
{
|
||||
"label": ROUND_LABELS[1],
|
||||
"round_num": 1,
|
||||
"east": east_r1,
|
||||
"west": west_r1,
|
||||
},
|
||||
{
|
||||
"label": ROUND_LABELS[2],
|
||||
"round_num": 2,
|
||||
"east": east_r2,
|
||||
"west": west_r2,
|
||||
},
|
||||
{
|
||||
"label": ROUND_LABELS[3],
|
||||
"round_num": 3,
|
||||
"east": east_cf,
|
||||
"west": west_cf,
|
||||
},
|
||||
{"label": ROUND_LABELS[4], "round_num": 4, "cup": cup},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _matchup(year, letter, series):
|
||||
"""Render-ready dict for one bracket slot. Empty when the series is unknown."""
|
||||
if not series:
|
||||
return {
|
||||
"letter": letter,
|
||||
"series_id": f"{year}-{letter}",
|
||||
"empty": True,
|
||||
"top": None,
|
||||
"bottom": None,
|
||||
"top_wins": 0,
|
||||
"bottom_wins": 0,
|
||||
"round": None,
|
||||
"winner_abbrev": None,
|
||||
"state": "pending",
|
||||
}
|
||||
|
||||
top = series.get("topSeedTeam") or {}
|
||||
bot = series.get("bottomSeedTeam") or {}
|
||||
top_wins = _to_int(series.get("topSeedWins"))
|
||||
bot_wins = _to_int(series.get("bottomSeedWins"))
|
||||
winning_id = series.get("winningTeamId")
|
||||
|
||||
winner_abbrev = None
|
||||
if winning_id is not None:
|
||||
if top.get("id") == winning_id:
|
||||
winner_abbrev = top.get("abbrev")
|
||||
elif bot.get("id") == winning_id:
|
||||
winner_abbrev = bot.get("abbrev")
|
||||
|
||||
if winner_abbrev:
|
||||
state = "complete"
|
||||
elif top_wins > 0 or bot_wins > 0:
|
||||
state = "active"
|
||||
else:
|
||||
state = "upcoming"
|
||||
|
||||
return {
|
||||
"letter": letter,
|
||||
"series_id": f"{year}-{letter}",
|
||||
"empty": False,
|
||||
"top": _team(top, series.get("topSeedRankAbbrev")),
|
||||
"bottom": _team(bot, series.get("bottomSeedRankAbbrev")),
|
||||
"top_wins": top_wins,
|
||||
"bottom_wins": bot_wins,
|
||||
"round": series.get("playoffRound"),
|
||||
"winner_abbrev": winner_abbrev,
|
||||
"state": state,
|
||||
}
|
||||
|
||||
|
||||
def _team(team, seed_abbrev=None):
|
||||
if not team:
|
||||
return None
|
||||
return {
|
||||
"id": team.get("id"),
|
||||
"abbrev": team.get("abbrev"),
|
||||
"name": (team.get("name") or {}).get("default"),
|
||||
"common_name": (team.get("commonName") or {}).get("default"),
|
||||
"logo": team.get("darkLogo") or team.get("logo"),
|
||||
"seed": seed_abbrev,
|
||||
}
|
||||
|
||||
|
||||
def _to_int(v, default=0):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
+200
-140
@@ -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")
|
||||
|
||||
@@ -33,9 +44,11 @@ def parse_games(scoreboard_data):
|
||||
for game in scoreboard_data.get("games", []):
|
||||
game_state = convert_game_state(game["gameState"])
|
||||
priority_comps = _priority_components(game)
|
||||
comeback = get_comeback_bonus(game)
|
||||
momentum = _momentum_components(game)
|
||||
importance_comps = _importance_components(game)
|
||||
total_priority = priority_comps["total"] + comeback + importance_comps["total"]
|
||||
total_priority = (
|
||||
priority_comps["total"] + momentum["total"] + importance_comps["total"]
|
||||
)
|
||||
extracted_info.append(
|
||||
{
|
||||
"Home Team": game["homeTeam"]["name"]["default"],
|
||||
@@ -63,10 +76,12 @@ def parse_games(scoreboard_data):
|
||||
"base": priority_comps["base"],
|
||||
"time": priority_comps["time"],
|
||||
"matchup_bonus": priority_comps["matchup_bonus"],
|
||||
"closeness": priority_comps["closeness"],
|
||||
"score_state": priority_comps["score_state"],
|
||||
"high_scoring": priority_comps["high_scoring"],
|
||||
"power_play": priority_comps["power_play"],
|
||||
"empty_net": priority_comps["empty_net"],
|
||||
"comeback": comeback,
|
||||
"comeback": momentum["comeback"],
|
||||
"goal_spike": momentum["goal_spike"],
|
||||
"importance": importance_comps["total"],
|
||||
"importance_season_weight": importance_comps["season_weight"],
|
||||
"importance_playoff_relevance": importance_comps[
|
||||
@@ -96,30 +111,46 @@ 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)
|
||||
|
||||
|
||||
def get_comeback_bonus(game):
|
||||
"""Persistent comeback bonus that scales with deficit recovered.
|
||||
def _momentum_components(game):
|
||||
"""Detects comeback recovery and fresh-goal spikes in a single pass.
|
||||
|
||||
Tracks the maximum score differential seen in the game. A recovery of 2+
|
||||
goals earns a sustained bonus that persists as long as the game remains
|
||||
close. One-goal swings are normal hockey and earn no bonus.
|
||||
Updates both caches exactly once per call. Returns:
|
||||
- comeback: persistent bonus while a 2+ goal deficit is being recovered
|
||||
- goal_spike: one-tick bonus on the refresh where a goal just landed
|
||||
"""
|
||||
zero = {"comeback": 0, "goal_spike": 0, "total": 0}
|
||||
if game["gameState"] not in ("LIVE", "CRIT"):
|
||||
return 0
|
||||
return zero
|
||||
if game["clock"]["inIntermission"]:
|
||||
return 0
|
||||
return zero
|
||||
|
||||
home_name = game["homeTeam"]["name"]["default"]
|
||||
away_name = game["awayTeam"]["name"]["default"]
|
||||
@@ -127,25 +158,54 @@ def get_comeback_bonus(game):
|
||||
|
||||
home_score = game["homeTeam"]["score"]
|
||||
away_score = game["awayTeam"]["score"]
|
||||
current = (home_score, away_score)
|
||||
current_diff = abs(home_score - away_score)
|
||||
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
|
||||
|
||||
# ── Goal spike (fires the single tick after a score changes) ─────────
|
||||
previous = _score_cache.get(key)
|
||||
goal_spike = 0
|
||||
if previous is not None and previous != current:
|
||||
if period >= 4:
|
||||
goal_spike = 100
|
||||
elif period == 3 and time_remaining <= 300:
|
||||
goal_spike = 80
|
||||
elif period == 3 and time_remaining <= 720:
|
||||
goal_spike = 60
|
||||
elif period == 3:
|
||||
goal_spike = 40
|
||||
else:
|
||||
goal_spike = 25
|
||||
|
||||
# ── Comeback tracking ────────────────────────────────────────────────
|
||||
tracker_max = _comeback_tracker.get(key, 0)
|
||||
if key in _score_cache:
|
||||
prev_diff = abs(_score_cache[key][0] - _score_cache[key][1])
|
||||
tracker_max = max(tracker_max, prev_diff)
|
||||
if previous is not None:
|
||||
tracker_max = max(tracker_max, abs(previous[0] - previous[1]))
|
||||
_comeback_tracker[key] = tracker_max
|
||||
_score_cache[key] = (home_score, away_score)
|
||||
_score_cache[key] = current
|
||||
|
||||
recovery = tracker_max - current_diff
|
||||
if recovery < 2 or tracker_max < 2:
|
||||
return 0
|
||||
comeback = 0
|
||||
if recovery >= 2 and tracker_max >= 2:
|
||||
base = {2: 50, 3: 90}.get(recovery, 120)
|
||||
period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2)
|
||||
tie_bonus = 20 if current_diff == 0 else 0
|
||||
comeback = int(base * period_mult + tie_bonus)
|
||||
|
||||
base = {2: 60, 3: 120}.get(recovery, 160)
|
||||
period_mult = {1: 0.6, 2: 0.8, 3: 1.0}.get(period, 1.2)
|
||||
tie_bonus = 30 if current_diff == 0 else 0
|
||||
return {
|
||||
"comeback": comeback,
|
||||
"goal_spike": goal_spike,
|
||||
"total": comeback + goal_spike,
|
||||
}
|
||||
|
||||
return int(base * period_mult + tie_bonus)
|
||||
|
||||
def get_comeback_bonus(game):
|
||||
return _momentum_components(game)["comeback"]
|
||||
|
||||
|
||||
def get_goal_spike(game):
|
||||
return _momentum_components(game)["goal_spike"]
|
||||
|
||||
|
||||
def convert_game_state(game_state):
|
||||
@@ -210,13 +270,42 @@ def _get_man_advantage(situation):
|
||||
return abs(home_total - away_total)
|
||||
|
||||
|
||||
def _score_state_bonus(diff, period, time_remaining):
|
||||
"""Unified score-state contribution: closeness reward AND blowout penalty,
|
||||
coherent across period and time remaining. Replaces closeness + diff
|
||||
penalty + late-3rd urgency as a single signal."""
|
||||
if period >= 4:
|
||||
# OT is always tied — flat tension bonus
|
||||
return 60
|
||||
|
||||
if period <= 2:
|
||||
return {0: 50, 1: 30, 2: 10, 3: -30, 4: -80}.get(diff, -120)
|
||||
|
||||
# Period 3 — depends on time remaining
|
||||
mins_left = time_remaining / 60
|
||||
if mins_left > 12:
|
||||
return {0: 70, 1: 40, 2: 0, 3: -50, 4: -120}.get(diff, -160)
|
||||
if mins_left > 6:
|
||||
return {0: 120, 1: 80, 2: 20, 3: -80, 4: -180}.get(diff, -240)
|
||||
if mins_left > 2:
|
||||
# Goalie-pull zone — 2-goal deficit becomes interesting again
|
||||
return {0: 180, 1: 140, 2: 50, 3: -80, 4: -200}.get(diff, -260)
|
||||
# Final 2 min — peak tension
|
||||
return {0: 220, 1: 180, 2: 80, 3: -50, 4: -220}.get(diff, -280)
|
||||
|
||||
|
||||
def _priority_components(game):
|
||||
"""Return a dict of all priority components plus the final total."""
|
||||
"""Return a dict of all priority components plus the final total.
|
||||
|
||||
Calibrated so typical late-P3 tied games land around 550-650 and only
|
||||
genuinely rare moments (playoff OT + comeback + PP) exceed 900.
|
||||
"""
|
||||
_zero = {
|
||||
"base": 0,
|
||||
"time": 0,
|
||||
"matchup_bonus": 0,
|
||||
"closeness": 0,
|
||||
"score_state": 0,
|
||||
"high_scoring": 0,
|
||||
"power_play": 0,
|
||||
"empty_net": 0,
|
||||
"total": 0,
|
||||
@@ -225,25 +314,32 @@ def _priority_components(game):
|
||||
if game["gameState"] in ["FINAL", "OFF", "PRE", "FUT"]:
|
||||
return _zero
|
||||
|
||||
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||
# Pushes intermission games to the bottom, retains relative sort order
|
||||
time_remaining = game.get("clock", {}).get("secondsRemaining", 0)
|
||||
if game["clock"]["inIntermission"]:
|
||||
return {**_zero, "total": -2000 - time_remaining}
|
||||
|
||||
period = game.get("periodDescriptor", {}).get("number", 0)
|
||||
home_score = game["homeTeam"]["score"]
|
||||
away_score = game["awayTeam"]["score"]
|
||||
score_difference = abs(home_score - away_score)
|
||||
total_goals = home_score + away_score
|
||||
is_playoff = game.get("gameType", 2) == 3
|
||||
|
||||
# ── 1. Base priority by period ────────────────────────────────────────
|
||||
# ── 1. Base priority by period (tightened) ───────────────────────────
|
||||
if is_playoff:
|
||||
base_priority = {1: 150, 2: 200, 3: 350}.get(period, 600 + (period - 4) * 150)
|
||||
# Multi-OT escalates aggressively (P4=500, P5=620, P6=760, P7=920…)
|
||||
base_priority = {1: 120, 2: 180, 3: 280}.get(
|
||||
period, 500 + (period - 4) * 120 + max(0, period - 5) * 20
|
||||
)
|
||||
else:
|
||||
base_priority = {1: 150, 2: 200, 3: 350, 4: 600, 5: 700}.get(period, 150)
|
||||
# Regular season: P4=5-min OT, P5=shootout
|
||||
base_priority = {1: 80, 2: 120, 3: 200, 4: 400, 5: 380}.get(period, 80)
|
||||
|
||||
# ── 2. Period length for time calculations ────────────────────────────
|
||||
# ── 2. Period length for time calculations ───────────────────────────
|
||||
period_length = (1200 if is_playoff else 300) if period >= 4 else 1200
|
||||
|
||||
# ── 3. Standings-quality matchup bonus ───────────────────────────────
|
||||
# Invert rank so that #1 (best) contributes the most quality points.
|
||||
# league_sequence 1=best, 32=worst → inverted: 32 quality pts for #1, 1 for #32.
|
||||
# ── 3. Matchup bonus (minor contributor now) ─────────────────────────
|
||||
home_standings = get_team_standings(game["homeTeam"]["name"]["default"])
|
||||
away_standings = get_team_standings(game["awayTeam"]["name"]["default"])
|
||||
home_quality = (33 - home_standings["league_sequence"]) + (
|
||||
@@ -252,115 +348,84 @@ def _priority_components(game):
|
||||
away_quality = (33 - away_standings["league_sequence"]) + (
|
||||
33 - away_standings["league_l10_sequence"]
|
||||
)
|
||||
# Higher period = matchup matters less (any OT is exciting regardless of teams)
|
||||
matchup_multiplier = {1: 1.5, 2: 1.5, 3: 1.25, 4: 1.0}.get(period, 1.0)
|
||||
matchup_bonus = (home_quality + away_quality) * matchup_multiplier
|
||||
# Max combined quality = 128. Divided by 5 → 0-25.6. Multiplier tightens further.
|
||||
matchup_raw = (home_quality + away_quality) / 5
|
||||
matchup_multiplier = {1: 1.5, 2: 1.25, 3: 1.0, 4: 0.5}.get(period, 0.5)
|
||||
matchup_bonus = int(matchup_raw * matchup_multiplier)
|
||||
|
||||
# ── Shootout: flat priority, no time component (rounds, not clock) ───
|
||||
# ── Shootout: flat skills-competition score, no time component ──────
|
||||
if period == 5 and not is_playoff:
|
||||
so_base = 550
|
||||
so_closeness = 80
|
||||
so_matchup = (home_quality + away_quality) * 1.0
|
||||
so_total = int(so_base + so_closeness + so_matchup)
|
||||
so_total = int(380 + 60 + matchup_bonus)
|
||||
return {
|
||||
"base": so_base,
|
||||
"base": 380,
|
||||
"time": 0,
|
||||
"matchup_bonus": int(so_matchup),
|
||||
"closeness": so_closeness,
|
||||
"matchup_bonus": matchup_bonus,
|
||||
"score_state": 60,
|
||||
"high_scoring": 0,
|
||||
"power_play": 0,
|
||||
"empty_net": 0,
|
||||
"total": so_total,
|
||||
}
|
||||
|
||||
# ── 4. Score-differential penalty (period-aware) ───────────────────────
|
||||
score_differential_adjustment = 0
|
||||
if period <= 2:
|
||||
adj = {0: 0, 1: 0, 2: 60, 3: 200, 4: 350}
|
||||
score_differential_adjustment = adj.get(
|
||||
score_difference, 350 + (score_difference - 4) * 100
|
||||
)
|
||||
elif period == 3:
|
||||
mins_left = time_remaining / 60
|
||||
if mins_left > 10:
|
||||
adj = {0: 0, 1: 0, 2: 80, 3: 250, 4: 400}
|
||||
elif mins_left > 5:
|
||||
adj = {0: 0, 1: 0, 2: 120, 3: 350, 4: 500}
|
||||
elif mins_left > 2:
|
||||
# Goalie-pull zone: 2-goal penalty DECREASES
|
||||
adj = {0: 0, 1: 0, 2: 80, 3: 450, 4: 600}
|
||||
else:
|
||||
# Final 2 min: 2-goal deficit with active goalie pull is exciting
|
||||
adj = {0: 0, 1: 0, 2: 60, 3: 550, 4: 700}
|
||||
score_differential_adjustment = adj.get(
|
||||
score_difference, adj[4] + (score_difference - 4) * 100
|
||||
)
|
||||
# OT: always tied, no penalty needed
|
||||
# ── 4. Unified score-state bonus ─────────────────────────────────────
|
||||
score_state = _score_state_bonus(score_difference, period, time_remaining)
|
||||
|
||||
base_priority -= score_differential_adjustment
|
||||
|
||||
# ── 5. Late-3rd urgency bonus ─────────────────────────────────────────
|
||||
if period == 3 and time_remaining <= 720:
|
||||
if score_difference == 0:
|
||||
base_priority += 100
|
||||
elif score_difference == 1:
|
||||
base_priority += 60
|
||||
|
||||
if period == 3 and time_remaining <= 360:
|
||||
if score_difference == 0:
|
||||
base_priority += 50
|
||||
elif score_difference == 1:
|
||||
base_priority += 30
|
||||
|
||||
# ── 6. Closeness bonus ───────────────────────────────────────────────
|
||||
closeness_bonus = {0: 80, 1: 50, 2: 20}.get(score_difference, 0)
|
||||
|
||||
# ── 7. Time priority (non-linear — final minutes weighted more) ─────
|
||||
time_multiplier = {4: 5, 3: 5, 2: 3}.get(period, 2.0 if period >= 5 else 1.5)
|
||||
# ── 5. Time priority (cap per period, non-linear toward end) ─────────
|
||||
time_priority_max = {1: 30, 2: 60, 3: 120}.get(period, 200 if is_playoff else 60)
|
||||
elapsed_fraction = (
|
||||
max(0.0, (period_length - time_remaining) / period_length)
|
||||
if period_length
|
||||
else 0
|
||||
)
|
||||
time_priority = (elapsed_fraction**1.5) * (period_length / 20) * time_multiplier
|
||||
time_priority = (elapsed_fraction**1.6) * time_priority_max
|
||||
|
||||
# ── 8. Power play bonus ───────────────────────────────────────────────
|
||||
# ── 6. High-scoring bonus for close games with 6+ combined goals ─────
|
||||
# Rewards the "shootout-y" vibe where next goal carries more weight.
|
||||
high_scoring_bonus = 0
|
||||
if total_goals >= 6 and score_difference <= 2:
|
||||
high_scoring_bonus = min((total_goals - 5) * 12, 60)
|
||||
if period < 3:
|
||||
high_scoring_bonus = int(high_scoring_bonus * 0.5)
|
||||
|
||||
# ── 7. Power play bonus (tightened) ──────────────────────────────────
|
||||
pp_bonus = 0
|
||||
situation = game.get("situation", {})
|
||||
home_descs = situation.get("homeTeam", {}).get("situationDescriptions", [])
|
||||
away_descs = situation.get("awayTeam", {}).get("situationDescriptions", [])
|
||||
if "PP" in home_descs or "PP" in away_descs:
|
||||
man_advantage = _get_man_advantage(situation)
|
||||
advantage_mult = 1.0 if man_advantage <= 1 else 1.6
|
||||
advantage_mult = 1.0 if man_advantage <= 1 else 1.5
|
||||
if period >= 4:
|
||||
pp_bonus = int(200 * advantage_mult)
|
||||
pp_bonus = int(120 * advantage_mult)
|
||||
elif period == 3 and time_remaining <= 300:
|
||||
pp_bonus = int(150 * advantage_mult)
|
||||
pp_bonus = int(90 * advantage_mult)
|
||||
elif period == 3 and time_remaining <= 720:
|
||||
pp_bonus = int(100 * advantage_mult)
|
||||
pp_bonus = int(60 * advantage_mult)
|
||||
elif period == 3:
|
||||
pp_bonus = int(50 * advantage_mult)
|
||||
pp_bonus = int(35 * advantage_mult)
|
||||
else:
|
||||
pp_bonus = int(30 * advantage_mult)
|
||||
pp_bonus = int(20 * advantage_mult)
|
||||
|
||||
# ── 9. Empty net bonus ───────────────────────────────────────────────
|
||||
# ── 8. Empty net bonus (tightened) ───────────────────────────────────
|
||||
en_bonus = 0
|
||||
if "EN" in home_descs or "EN" in away_descs:
|
||||
if period >= 4:
|
||||
en_bonus = 250
|
||||
en_bonus = 180
|
||||
elif period == 3 and time_remaining <= 180:
|
||||
en_bonus = 200
|
||||
en_bonus = 140
|
||||
elif period == 3 and time_remaining <= 360:
|
||||
en_bonus = 150
|
||||
en_bonus = 100
|
||||
else:
|
||||
en_bonus = 75
|
||||
en_bonus = 50
|
||||
|
||||
logger.debug(
|
||||
"priority components — base: %s, time: %.0f, matchup_bonus: %.0f, "
|
||||
"closeness: %s, pp: %s, en: %s",
|
||||
"priority components — base: %s, time: %.0f, matchup: %s, "
|
||||
"score_state: %s, high_scoring: %s, pp: %s, en: %s",
|
||||
base_priority,
|
||||
time_priority,
|
||||
matchup_bonus,
|
||||
closeness_bonus,
|
||||
score_state,
|
||||
high_scoring_bonus,
|
||||
pp_bonus,
|
||||
en_bonus,
|
||||
)
|
||||
@@ -369,20 +434,18 @@ def _priority_components(game):
|
||||
base_priority
|
||||
+ time_priority
|
||||
+ matchup_bonus
|
||||
+ closeness_bonus
|
||||
+ score_state
|
||||
+ high_scoring_bonus
|
||||
+ pp_bonus
|
||||
+ en_bonus
|
||||
)
|
||||
|
||||
# Pushes intermission games to the bottom, retains relative sort order
|
||||
if game["clock"]["inIntermission"]:
|
||||
return {**_zero, "total": -2000 - time_remaining}
|
||||
|
||||
return {
|
||||
"base": base_priority,
|
||||
"time": int(time_priority),
|
||||
"matchup_bonus": int(matchup_bonus),
|
||||
"closeness": closeness_bonus,
|
||||
"matchup_bonus": matchup_bonus,
|
||||
"score_state": score_state,
|
||||
"high_scoring": high_scoring_bonus,
|
||||
"power_play": pp_bonus,
|
||||
"empty_net": en_bonus,
|
||||
"total": final_priority,
|
||||
@@ -428,15 +491,14 @@ def get_team_standings(team_name):
|
||||
|
||||
|
||||
def _playoff_importance(game):
|
||||
"""Importance for playoff games based on series context and round."""
|
||||
"""Importance for playoff games — round + series context. Max 150."""
|
||||
series = game.get("seriesStatus", {})
|
||||
if not series:
|
||||
# No series data available — flat playoff bonus
|
||||
return {
|
||||
"season_weight": 1.0,
|
||||
"playoff_relevance": 0.50,
|
||||
"rivalry": 1.0,
|
||||
"total": 100,
|
||||
"total": 60,
|
||||
}
|
||||
|
||||
round_num = series.get("round", 1)
|
||||
@@ -448,17 +510,17 @@ def _playoff_importance(game):
|
||||
round_mult = {1: 1.0, 2: 1.15, 3: 1.30, 4: 1.50}.get(round_num, 1.0)
|
||||
|
||||
if max_wins == 3 and min_wins == 3:
|
||||
series_factor = 1.0
|
||||
series_factor = 1.0 # Game 7
|
||||
elif max_wins == 3:
|
||||
series_factor = 0.85
|
||||
series_factor = 0.90 # Elimination game
|
||||
elif max_wins == 2 and min_wins == 2:
|
||||
series_factor = 0.70
|
||||
series_factor = 0.75 # Pivotal tied series
|
||||
elif max_wins == 2:
|
||||
series_factor = 0.55
|
||||
series_factor = 0.60
|
||||
else:
|
||||
series_factor = 0.40
|
||||
series_factor = 0.45
|
||||
|
||||
importance = min(int(series_factor * round_mult * 200), 200)
|
||||
importance = min(int(series_factor * round_mult * 100), 150)
|
||||
|
||||
return {
|
||||
"season_weight": round_mult,
|
||||
@@ -469,7 +531,7 @@ def _playoff_importance(game):
|
||||
|
||||
|
||||
def _importance_components(game):
|
||||
"""Return a dict of all importance components plus the final total."""
|
||||
"""Regular-season importance — season_weight × stakes × rivalry. Max 100."""
|
||||
_zero = {"season_weight": 0.0, "playoff_relevance": 0.0, "rivalry": 1.0, "total": 0}
|
||||
|
||||
if game["gameState"] in ("FINAL", "OFF"):
|
||||
@@ -482,30 +544,29 @@ def _importance_components(game):
|
||||
home_st = get_team_standings(game["homeTeam"]["name"]["default"])
|
||||
away_st = get_team_standings(game["awayTeam"]["name"]["default"])
|
||||
|
||||
# Season weight — near-zero before game 30, sharp ramp 55-70, max at 82
|
||||
# Season weight — zero before game 30, linear ramp to 1.0 at game 70+
|
||||
avg_gp = (home_st["games_played"] + away_st["games_played"]) / 2
|
||||
if avg_gp <= 30:
|
||||
season_weight = 0.05
|
||||
season_weight = 0.0
|
||||
else:
|
||||
t = (avg_gp - 30) / (82 - 30)
|
||||
season_weight = min(t**1.8, 1.0)
|
||||
season_weight = min((avg_gp - 30) / 40, 1.0)
|
||||
|
||||
# Playoff relevance — peaks for bubble teams (wildcard rank ~17-19)
|
||||
# Playoff stakes — peaks on the bubble, drops for locked-in or out
|
||||
best_wc = min(
|
||||
home_st["wildcard_sequence"] or 32, away_st["wildcard_sequence"] or 32
|
||||
)
|
||||
if best_wc <= 12:
|
||||
playoff_relevance = 0.60
|
||||
if best_wc <= 8:
|
||||
stakes = 0.5 # locked in, pressure off
|
||||
elif best_wc <= 16:
|
||||
playoff_relevance = 0.85
|
||||
elif best_wc <= 19:
|
||||
playoff_relevance = 1.00
|
||||
elif best_wc <= 23:
|
||||
playoff_relevance = 0.65
|
||||
stakes = 0.8 # comfortable, meaningful
|
||||
elif best_wc <= 20:
|
||||
stakes = 1.0 # bubble, every point critical
|
||||
elif best_wc <= 24:
|
||||
stakes = 0.5 # slipping
|
||||
else:
|
||||
playoff_relevance = 0.15
|
||||
stakes = 0.2 # effectively out
|
||||
|
||||
# Division/conference rivalry multiplier
|
||||
# Rivalry — division > conference > other
|
||||
home_div = home_st["division_abbrev"]
|
||||
away_div = away_st["division_abbrev"]
|
||||
home_conf = home_st["conference_abbrev"]
|
||||
@@ -517,21 +578,20 @@ def _importance_components(game):
|
||||
else:
|
||||
rivalry_multiplier = 1.0
|
||||
|
||||
raw = season_weight * playoff_relevance * rivalry_multiplier
|
||||
importance = max(0, min(int((raw / 1.4) * 150), 150))
|
||||
importance = int(season_weight * stakes * rivalry_multiplier * 70)
|
||||
importance = max(0, min(importance, 100))
|
||||
|
||||
logger.debug(
|
||||
"importance components — season_weight: %.3f, playoff_relevance: %.2f, "
|
||||
"rivalry: %.1f, importance: %s",
|
||||
"importance — season_weight: %.2f, stakes: %.2f, rivalry: %.1f, total: %s",
|
||||
season_weight,
|
||||
playoff_relevance,
|
||||
stakes,
|
||||
rivalry_multiplier,
|
||||
importance,
|
||||
)
|
||||
|
||||
return {
|
||||
"season_weight": round(season_weight, 3),
|
||||
"playoff_relevance": playoff_relevance,
|
||||
"playoff_relevance": stakes,
|
||||
"rivalry": rivalry_multiplier,
|
||||
"total": importance,
|
||||
}
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
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 _game_number(game, state):
|
||||
"""This card's game number. seriesStatus counts wins through the current
|
||||
payload, so once a game goes FINAL the win for this game is already banked
|
||||
and state['game_number'] (hi+lo+1) points at the *next* game. For a finished
|
||||
card, pin to hi+lo. The scoreboard payload doesn't carry a raw gameNumber,
|
||||
but we honor it if present (e.g. from the series-detail endpoint)."""
|
||||
raw = game.get("gameNumber")
|
||||
if isinstance(raw, int) and raw > 0:
|
||||
return raw
|
||||
if game.get("gameState") in ("FINAL", "OFF"):
|
||||
return max(1, state["hi"] + state["lo"])
|
||||
return state["game_number"]
|
||||
|
||||
|
||||
def series_blurb(game):
|
||||
"""One sentence of series context for a playoff card."""
|
||||
state = series_state(game.get("seriesStatus", {}))
|
||||
leader_name = _leader_name(game, state)
|
||||
trailer_name = _trailer_name(game, state)
|
||||
is_final = game.get("gameState") in ("FINAL", "OFF")
|
||||
|
||||
# Stake / opener blurbs describe what's *about* to happen. For a FINAL card
|
||||
# the seriesStatus already includes this game, so the stake really points at
|
||||
# the next matchup \u2014 fall through to a generic series-score blurb instead.
|
||||
if not is_final:
|
||||
if state["is_game7"]:
|
||||
return "Win-or-go-home"
|
||||
if state["is_clincher"] and leader_name:
|
||||
return f"{leader_name} can close it out"
|
||||
if state["is_pivotal"]:
|
||||
return "Series tied 2\u20112 \u2014 pivotal"
|
||||
if state["is_opener"]:
|
||||
return "Series opener"
|
||||
if leader_name and trailer_name:
|
||||
return f"{leader_name} lead {state['hi']}\u2011{state['lo']}"
|
||||
if state["hi"] == state["lo"]:
|
||||
return f"Series even at {state['hi']}\u2011{state['lo']}"
|
||||
return ""
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Stake badges describe the *upcoming* game. Once a game is FINAL the
|
||||
# seriesStatus reflects post-game wins, so the predicate now points at the
|
||||
# next card in the series — don't stamp it onto the one that's already done.
|
||||
if game.get("gameState") not in ("FINAL", "OFF"):
|
||||
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 line rendered above the card, e.g. 'Game 2 of 7'."""
|
||||
state = series_state(game.get("seriesStatus", {}))
|
||||
return f"Game {_game_number(game, state)} of 7"
|
||||
|
||||
|
||||
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):
|
||||
"""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` is injected by the caller (from the playoff_cache module) scoped to
|
||||
the max observed round so the banner resets at each round boundary.
|
||||
"""
|
||||
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,
|
||||
"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"])
|
||||
# Only pending/live games can still become the clincher or Game 7
|
||||
# today. Once a card is FINAL its seriesStatus points at the next game.
|
||||
if g.get("gameState") in ("FINAL", "OFF"):
|
||||
continue
|
||||
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,
|
||||
"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,295 @@
|
||||
"""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)
|
||||
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
|
||||
|
||||
|
||||
# ── Game-number enrichment ────────────────────────────────────────
|
||||
|
||||
|
||||
def enrich_game_numbers(raw_games):
|
||||
"""Inject gameNumber from cached series data into raw score-endpoint games.
|
||||
|
||||
The /v1/score/{date} endpoint omits gameNumber. For future dates the
|
||||
fallback computation (top_wins + bot_wins + 1) gives every game in a
|
||||
series the same number. The series-detail endpoint includes gameNumber,
|
||||
so we cross-reference by game id.
|
||||
"""
|
||||
need = {}
|
||||
for game in raw_games or []:
|
||||
if game.get("gameType") != 3:
|
||||
continue
|
||||
if isinstance(game.get("gameNumber"), int) and game["gameNumber"] > 0:
|
||||
continue
|
||||
ss = game.get("seriesStatus") or {}
|
||||
letter = ss.get("seriesLetter") or ss.get("seriesAbbrev")
|
||||
if not letter:
|
||||
continue
|
||||
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
|
||||
sid = f"{year}-{letter.upper()}"
|
||||
need.setdefault(sid, []).append(game)
|
||||
|
||||
for sid, games in need.items():
|
||||
payload = fetch_series(sid)
|
||||
if not payload:
|
||||
continue
|
||||
lookup = {}
|
||||
for sg in payload.get("games") or []:
|
||||
gid = sg.get("id")
|
||||
gn = sg.get("gameNumber")
|
||||
if gid is not None and isinstance(gn, int) and gn > 0:
|
||||
lookup[gid] = gn
|
||||
for game in games:
|
||||
gid = game.get("id")
|
||||
if gid is not None and gid in lookup:
|
||||
game["gameNumber"] = lookup[gid]
|
||||
|
||||
|
||||
# ── Per-round start dates (drive the "Day N" banner) ──────────────
|
||||
|
||||
ROUND_DATES_KEY = "meta:round_start_dates"
|
||||
|
||||
|
||||
def refresh_round_start_dates(year=None):
|
||||
"""Walk the cached bracket + per-series schedules; upsert per-round start dates.
|
||||
|
||||
For each series in the cached bracket, fetches that series' schedule
|
||||
(honoring the TTL cache) and computes the earliest Eastern game date
|
||||
within the series. Aggregates to `min(startDate)` per playoffRound and
|
||||
merges into the `meta:round_start_dates` cache entry.
|
||||
|
||||
Returns the full merged mapping {round_num_str: ISO date} or None if the
|
||||
bracket isn't cached yet.
|
||||
"""
|
||||
if year is None:
|
||||
year = datetime.now(EASTERN).year
|
||||
|
||||
bracket, _ = get_bracket(year)
|
||||
if bracket is None:
|
||||
return None
|
||||
|
||||
existing, _ = _get(ROUND_DATES_KEY)
|
||||
merged = dict(existing) if existing else {}
|
||||
|
||||
observed = {}
|
||||
for series in bracket.get("series", []) or []:
|
||||
letter = series.get("seriesLetter")
|
||||
round_num = series.get("playoffRound")
|
||||
if not letter or not round_num:
|
||||
continue
|
||||
payload = fetch_series(f"{year}-{letter}")
|
||||
if not payload:
|
||||
continue
|
||||
for game in payload.get("games", []) or []:
|
||||
start_utc = game.get("startTimeUTC")
|
||||
if not start_utc:
|
||||
continue
|
||||
try:
|
||||
local_date = (
|
||||
datetime.fromisoformat(start_utc.replace("Z", "+00:00"))
|
||||
.astimezone(EASTERN)
|
||||
.date()
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
current = observed.get(round_num)
|
||||
if current is None or local_date < current:
|
||||
observed[round_num] = local_date
|
||||
|
||||
for round_num, start_date in observed.items():
|
||||
merged[str(round_num)] = start_date.isoformat()
|
||||
|
||||
if merged:
|
||||
_put(ROUND_DATES_KEY, merged)
|
||||
return merged or None
|
||||
|
||||
|
||||
def get_round_start_date(round_num):
|
||||
"""Return the Eastern date round `round_num` began, or None if unknown."""
|
||||
payload, _ = _get(ROUND_DATES_KEY)
|
||||
if not payload:
|
||||
return None
|
||||
iso = payload.get(str(round_num))
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(iso)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def day_n_for_round(round_num, now=None):
|
||||
"""Day number within a playoff round (Day 1 = round's first game date).
|
||||
|
||||
Returns the day number (>= 1) or None when the round hasn't been anchored.
|
||||
"""
|
||||
if round_num is None:
|
||||
return None
|
||||
start = get_round_start_date(round_num)
|
||||
if start is None:
|
||||
return None
|
||||
now = now or datetime.now(EASTERN)
|
||||
n = (now.date() - start).days + 1
|
||||
return n if n >= 1 else None
|
||||
+125
-16
@@ -1,10 +1,57 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from flask import render_template, jsonify, send_from_directory
|
||||
import requests as http_requests
|
||||
from flask import (
|
||||
abort,
|
||||
make_response,
|
||||
render_template,
|
||||
jsonify,
|
||||
request,
|
||||
send_from_directory,
|
||||
)
|
||||
|
||||
from app import app
|
||||
from app import APP_VERSION, app, static_v
|
||||
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_for_round,
|
||||
enrich_game_numbers,
|
||||
fetch_series,
|
||||
get_bracket,
|
||||
parse_series_id,
|
||||
refresh_bracket,
|
||||
)
|
||||
from app.series_view import build_series_view
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_EASTERN = ZoneInfo("America/New_York")
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fetch_date(date_str):
|
||||
url = f"https://api-web.nhle.com/v1/score/{date_str}"
|
||||
try:
|
||||
resp = http_requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except http_requests.RequestException as e:
|
||||
_logger.error("Failed to fetch scores for %s: %s", date_str, e)
|
||||
return None
|
||||
|
||||
|
||||
def _max_playoff_round(raw_games):
|
||||
max_round = 0
|
||||
for g in raw_games or []:
|
||||
if g.get("gameType") != 3:
|
||||
continue
|
||||
r = (g.get("seriesStatus") or {}).get("round") or 0
|
||||
if r > max_round:
|
||||
max_round = r
|
||||
return max_round or None
|
||||
|
||||
|
||||
@app.route("/manifest.json")
|
||||
@@ -14,7 +61,17 @@ def manifest():
|
||||
|
||||
@app.route("/sw.js")
|
||||
def service_worker():
|
||||
response = send_from_directory(app.static_folder, "sw.js")
|
||||
precache = [
|
||||
"/",
|
||||
static_v("styles.css"),
|
||||
static_v("script.js"),
|
||||
static_v("icon-192x192.png"),
|
||||
static_v("icon-512x512.png"),
|
||||
"/manifest.json",
|
||||
]
|
||||
body = render_template("sw.js.j2", app_version=APP_VERSION, precache=precache)
|
||||
response = make_response(body)
|
||||
response.headers["Content-Type"] = "application/javascript; charset=utf-8"
|
||||
response.headers["Service-Worker-Allowed"] = "/"
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
@@ -34,31 +91,83 @@ def index():
|
||||
|
||||
@app.route("/scoreboard")
|
||||
def get_scoreboard():
|
||||
try:
|
||||
with open(SCOREBOARD_DATA_FILE, "r") as json_file:
|
||||
scoreboard_data = json.load(json_file)
|
||||
except FileNotFoundError:
|
||||
return jsonify({"error": "Failed to retrieve scoreboard data. File not found."})
|
||||
except json.JSONDecodeError:
|
||||
return jsonify(
|
||||
{"error": "Failed to retrieve scoreboard data. Invalid JSON format."}
|
||||
)
|
||||
date_param = request.args.get("date")
|
||||
today_str = datetime.now(_EASTERN).strftime("%Y-%m-%d")
|
||||
|
||||
if date_param and date_param != today_str:
|
||||
try:
|
||||
datetime.strptime(date_param, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400
|
||||
scoreboard_data = _fetch_date(date_param)
|
||||
if not scoreboard_data:
|
||||
return jsonify(
|
||||
{"error": "Failed to retrieve scoreboard data for that date."}
|
||||
)
|
||||
else:
|
||||
try:
|
||||
with open(SCOREBOARD_DATA_FILE, "r") as json_file:
|
||||
scoreboard_data = json.load(json_file)
|
||||
except FileNotFoundError:
|
||||
return jsonify(
|
||||
{"error": "Failed to retrieve scoreboard data. File not found."}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify(
|
||||
{"error": "Failed to retrieve scoreboard data. Invalid JSON format."}
|
||||
)
|
||||
|
||||
if scoreboard_data:
|
||||
raw_games = scoreboard_data.get("games", [])
|
||||
enrich_game_numbers(raw_games)
|
||||
games = parse_games(scoreboard_data)
|
||||
max_round = _max_playoff_round(raw_games)
|
||||
n = day_n_for_round(max_round) if max_round else None
|
||||
meta = today_meta(raw_games, day_n=n)
|
||||
|
||||
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, refresh_round_start_dates
|
||||
from app.standings import refresh_standings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -12,6 +13,11 @@ 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)
|
||||
schedule.every(21600).seconds.do(refresh_round_start_dates)
|
||||
# Populate the cache once at startup so the banner has data immediately.
|
||||
refresh_bracket()
|
||||
refresh_round_start_dates()
|
||||
logger.info("Background scheduler started")
|
||||
while True:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""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,
|
||||
"games": normalized_games,
|
||||
"played_games": played,
|
||||
"next_game": next_game,
|
||||
"series_logo": payload.get("seriesLogo"),
|
||||
"has_live": any(g["live"] for g in normalized_games),
|
||||
}
|
||||
|
||||
|
||||
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 _format_start(start_utc):
|
||||
if not start_utc:
|
||||
return "", ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(start_utc.replace("Z", "+00:00")).astimezone(
|
||||
EASTERN
|
||||
)
|
||||
except ValueError:
|
||||
return "", ""
|
||||
hour = dt.strftime("%I").lstrip("0") or "12"
|
||||
time_str = f"{hour}:{dt.strftime('%M %p')} ET"
|
||||
date_str = f"{dt.strftime('%a %b')} {dt.day}"
|
||||
return time_str, date_str
|
||||
|
||||
|
||||
def _to_int(value, default=0):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
+321
-35
@@ -1,19 +1,74 @@
|
||||
let failCount = 0;
|
||||
const STALE_THRESHOLD = 3;
|
||||
|
||||
// ── Date Navigation ──────────────────────────────────
|
||||
|
||||
function localDateStr() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
let viewingDate = localDateStr();
|
||||
|
||||
function isToday() {
|
||||
return viewingDate === localDateStr();
|
||||
}
|
||||
|
||||
function shiftDate(offset) {
|
||||
const [y, m, d] = viewingDate.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, d + offset);
|
||||
viewingDate = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
|
||||
updateDateLabel();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr) {
|
||||
if (dateStr === localDateStr()) return 'Today';
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, d);
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (dt.toDateString() === yesterday.toDateString()) return 'Yesterday';
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
if (dt.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
|
||||
return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function updateDateLabel() {
|
||||
const label = document.getElementById('date-label');
|
||||
if (label) label.textContent = formatDateLabel(viewingDate);
|
||||
}
|
||||
|
||||
async function fetchScoreboardData() {
|
||||
const url = isToday() ? '/scoreboard' : `/scoreboard?date=${viewingDate}`;
|
||||
try {
|
||||
const res = await fetch('/scoreboard');
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
failCount = 0;
|
||||
setStale(false);
|
||||
updateScoreboard(await res.json());
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch scoreboard data:', e);
|
||||
failCount++;
|
||||
if (failCount >= STALE_THRESHOLD) setStale(true);
|
||||
}
|
||||
}
|
||||
|
||||
function setStale(stale) {
|
||||
document.getElementById('stale-banner').classList.toggle('hidden', !stale);
|
||||
document.querySelector('main').classList.toggle('stale', stale);
|
||||
}
|
||||
|
||||
function updateScoreboard(data) {
|
||||
applyMeta(data.meta);
|
||||
|
||||
const sections = [
|
||||
{ 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 },
|
||||
{ sectionId: 'final-section', gridId: 'final-games-section', games: data.final_games, render: renderFinalGame },
|
||||
{ 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 },
|
||||
{ sectionId: 'final-section', gridId: 'final-games-section', games: data.final_games, render: renderFinalGame },
|
||||
];
|
||||
|
||||
for (const { sectionId, gridId, games, render } of sections) {
|
||||
@@ -31,24 +86,104 @@ function updateScoreboard(data) {
|
||||
if (hasGames) restoreClocks(grid, clockSnapshot);
|
||||
}
|
||||
|
||||
const anyGames = sections.some(s => s.games && s.games.length > 0);
|
||||
document.getElementById('empty-state').classList.toggle('hidden', anyGames);
|
||||
|
||||
restoreScroll();
|
||||
|
||||
const liveCount = (data.live_games || []).length + (data.intermission_games || []).length + (data.pinned_games || []).filter(g => g['Game State'] === 'LIVE').length;
|
||||
document.title = liveCount ? `NHL Scoreboard (${liveCount} Live)` : 'NHL Scoreboard';
|
||||
|
||||
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) {
|
||||
setText(dayEl, `Day ${meta.day_n}`);
|
||||
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,25 +198,35 @@ 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>
|
||||
${dot}
|
||||
</div>
|
||||
${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 +234,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 +254,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 ─────────────────────────────────────────
|
||||
@@ -115,9 +300,16 @@ function teamRow(game, side, state) {
|
||||
const score = game[`${side} Score`];
|
||||
const sog = game[`${side} Shots`];
|
||||
const record = game[`${side} Record`];
|
||||
const pp = game[`${side} Power Play`];
|
||||
|
||||
const sogHtml = (state === 'live' || state === 'final') && sog !== undefined
|
||||
? `<span class="team-sog">${sog} SOG</span>` : '';
|
||||
const ppHtml = state === 'live' && pp
|
||||
? teamPpIndicator(pp, game['Time Running'])
|
||||
: '';
|
||||
|
||||
const subParts = [sogHtml, ppHtml].filter(Boolean).join('');
|
||||
const subline = subParts ? `<div class="team-subline">${subParts}</div>` : '';
|
||||
|
||||
const right = state === 'pre'
|
||||
? `<span class="team-record">${record}</span>`
|
||||
@@ -128,24 +320,24 @@ function teamRow(game, side, state) {
|
||||
<img src="${logo}" alt="${name} logo" class="team-logo">
|
||||
<div class="team-meta">
|
||||
<span class="team-name">${name}</span>
|
||||
${sogHtml}
|
||||
${subline}
|
||||
</div>
|
||||
${right}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function ppBadge(game) {
|
||||
const awayPP = game['Away Power Play'];
|
||||
const homePP = game['Home Power Play'];
|
||||
const pp = awayPP || homePP;
|
||||
if (!pp) return '';
|
||||
|
||||
const team = awayPP ? game['Away Team'] : game['Home Team'];
|
||||
function teamPpIndicator(pp, running) {
|
||||
const timeStr = pp.replace('PP ', '');
|
||||
if (!running) {
|
||||
return `<span class="team-pp">PP ${timeStr}</span>`;
|
||||
}
|
||||
const seconds = timeToSeconds(timeStr);
|
||||
const attrs = `data-seconds="${seconds}" data-received-at="${Date.now()}" data-pp-clock`;
|
||||
return `<span class="team-pp">PP <span ${attrs}>${timeStr}</span></span>`;
|
||||
}
|
||||
|
||||
return `<span class="badge badge-pp">PP ${team} <span ${attrs}>${timeStr}</span></span>`;
|
||||
function gameKey(game) {
|
||||
return `${game['Away Team']}|${game['Home Team']}`;
|
||||
}
|
||||
|
||||
// ── Gauge ────────────────────────────────────────────
|
||||
@@ -197,7 +389,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,19 +417,114 @@ 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;
|
||||
|
||||
const candidates = [...(data.pinned_games || []), ...(data.live_games || [])];
|
||||
const hasPlayoffOT = candidates.some(g => g['Playoff OT']);
|
||||
|
||||
if (hasPlayoffOT && Notification.permission === 'default') {
|
||||
Notification.requestPermission().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (Notification.permission !== 'granted') return;
|
||||
|
||||
const seen = seenOTKeys();
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Update Toast ─────────────────────────────────────
|
||||
|
||||
function showUpdateToast() {
|
||||
if (document.getElementById('update-toast')) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'update-toast';
|
||||
toast.className = 'update-toast';
|
||||
toast.innerHTML = 'New version available <button class="update-toast-btn">Reload</button>';
|
||||
toast.querySelector('button').addEventListener('click', () => location.reload());
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
// ── Scroll Restoration ───────────────────────────────
|
||||
|
||||
const SCROLL_KEY = 'nhl_scroll_y';
|
||||
let scrollRestored = false;
|
||||
|
||||
function saveScroll() {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
|
||||
}
|
||||
|
||||
function restoreScroll() {
|
||||
if (scrollRestored) return;
|
||||
scrollRestored = true;
|
||||
const y = parseInt(sessionStorage.getItem(SCROLL_KEY) || '0', 10);
|
||||
if (y > 0) {
|
||||
requestAnimationFrame(() => window.scrollTo(0, y));
|
||||
}
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────
|
||||
|
||||
function autoRefresh() {
|
||||
let refreshTimer = null;
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
fetchScoreboardData();
|
||||
setTimeout(autoRefresh, 5000);
|
||||
if (isToday()) {
|
||||
refreshTimer = setTimeout(startAutoRefresh, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; }
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
autoRefresh();
|
||||
updateDateLabel();
|
||||
document.getElementById('date-prev').addEventListener('click', () => shiftDate(-1));
|
||||
document.getElementById('date-next').addEventListener('click', () => shiftDate(1));
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('.series-link')) saveScroll();
|
||||
});
|
||||
startAutoRefresh();
|
||||
setInterval(tickClocks, 1000);
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(err => {
|
||||
console.warn('Service worker registration failed:', err);
|
||||
});
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
showUpdateToast();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+974
-15
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
const CACHE = 'nhl-scoreboard-v1';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/styles.css',
|
||||
'/static/script.js',
|
||||
'/static/icon-192x192.png',
|
||||
'/static/icon-512x512.png',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const { pathname } = new URL(event.request.url);
|
||||
|
||||
// Network-first for the live scoreboard API — stale data is useless
|
||||
if (pathname === '/scoreboard') {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for everything else (static assets, shell)
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(response => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE).then(c => c.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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,83 @@
|
||||
<!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_v('icon-32x32.png') }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}">
|
||||
<script defer src="https://tracking.thewrightserver.net/script.js" data-website-id="d6bd9925-cb2d-4164-9685-bfb7642c41e4"></script>
|
||||
</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 {% if bracket.current_round == 1 %}bracket-col-active{% endif %}">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 {% if bracket.current_round == 2 %}bracket-col-active{% endif %}">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 {% if bracket.current_round == 3 %}bracket-col-active{% endif %}">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 {% if bracket.current_round == 4 %}bracket-col-active{% endif %}">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 {% if bracket.current_round == 3 %}bracket-col-active{% endif %}">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 {% if bracket.current_round == 2 %}bracket-col-active{% endif %}">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 {% if bracket.current_round == 1 %}bracket-col-active{% endif %}">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 rnd.round_num == bracket.current_round or (bracket.current_round is none and 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>
|
||||
@@ -8,15 +8,59 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="NHL Scores">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" href="/static/icon-32x32.png">
|
||||
<link rel="apple-touch-icon" href="/static/icon-180x180.png">
|
||||
<link rel="stylesheet" type="text/css" href="/static/styles.css">
|
||||
<link rel="icon" type="image/png" href="{{ static_v('icon-32x32.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ static_v('icon-180x180.png') }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}">
|
||||
<script defer src="https://tracking.thewrightserver.net/script.js" data-website-id="d6bd9925-cb2d-4164-9685-bfb7642c41e4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="header-title">NHL Scoreboard</span>
|
||||
<nav class="date-nav" aria-label="Date navigation">
|
||||
<button id="date-prev" class="date-btn" aria-label="Previous day">←</button>
|
||||
<span id="date-label" class="date-label"></span>
|
||||
<button id="date-next" class="date-btn" aria-label="Next day">→</button>
|
||||
</nav>
|
||||
</header>
|
||||
<section id="playoff-banner" class="playoff-banner hidden" aria-hidden="true">
|
||||
<a class="banner-main" href="/bracket" aria-label="View the playoff bracket">
|
||||
<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>
|
||||
</section>
|
||||
<div id="stale-banner" class="stale-banner hidden">Connection lost — scores may be outdated</div>
|
||||
<main>
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<p class="empty-state-heading">No games scheduled today</p>
|
||||
<p class="empty-state-sub">Check back tomorrow</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -34,6 +78,6 @@
|
||||
<div id="final-games-section" class="games-grid"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/script.js"></script>
|
||||
<script src="{{ static_v('script.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<!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_v('icon-32x32.png') }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{ static_v('styles.css') }}">
|
||||
{% if series.has_live %}<meta http-equiv="refresh" content="30">{% endif %}
|
||||
<script defer src="https://tracking.thewrightserver.net/script.js" data-website-id="d6bd9925-cb2d-4164-9685-bfb7642c41e4"></script>
|
||||
</head>
|
||||
<body class="playoff-mode series-mode">
|
||||
<header class="series-header">
|
||||
<a class="header-title header-link" href="/">← NHL Scoreboard</a>
|
||||
</header>
|
||||
<main class="series-main">
|
||||
<section class="series-hero">
|
||||
<div class="series-hero-eyebrow">
|
||||
<a class="badge badge-round badge-round-link" href="/bracket" aria-label="View the playoff bracket">{{ series.round_label|upper }}</a>
|
||||
{% 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-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>
|
||||
</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 }}{% 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 }}{% 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>
|
||||
@@ -0,0 +1,60 @@
|
||||
const CACHE = 'nhl-scoreboard-{{ app_version }}';
|
||||
const PRECACHE = {{ precache | tojson }};
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
const { pathname } = new URL(event.request.url);
|
||||
|
||||
// Network-first for the live scoreboard API — stale data is useless
|
||||
if (pathname === '/scoreboard') {
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for HTML pages (root, bracket, series detail) so the
|
||||
// very next request after a deploy lands the new asset URLs
|
||||
if (pathname === '/' || 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;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate for everything else (versioned static assets,
|
||||
// manifest, icons): return cached bytes immediately, refresh in the
|
||||
// background so the next load is current
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
const networkFetch = 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(() => cached);
|
||||
return cached || networkFetch;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-v --tb=short"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 85
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if __name__ == .__main__.",
|
||||
]
|
||||
@@ -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,53 @@ 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",
|
||||
game_number=None,
|
||||
**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"
|
||||
|
||||
game = 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,
|
||||
)
|
||||
if game_number is not None:
|
||||
game["gameNumber"] = game_number
|
||||
return game
|
||||
|
||||
|
||||
LIVE_GAME = make_game()
|
||||
PRE_GAME = make_game(
|
||||
game_state="FUT", home_score=0, away_score=0, period=0, seconds_remaining=1200
|
||||
@@ -89,9 +140,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,135 @@
|
||||
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]
|
||||
+145
-42
@@ -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,
|
||||
@@ -307,9 +307,9 @@ class TestEmptyNetBonus:
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 200
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 140
|
||||
|
||||
def test_en_mid_p3_adds_150(self, mocker):
|
||||
def test_en_mid_p3_adds_100(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -325,9 +325,9 @@ class TestEmptyNetBonus:
|
||||
"timeRemaining": "5:00",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 150
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 100
|
||||
|
||||
def test_en_ot_adds_250(self, mocker):
|
||||
def test_en_ot_adds_180(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -343,7 +343,7 @@ class TestEmptyNetBonus:
|
||||
"timeRemaining": "10:00",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 250
|
||||
assert calculate_game_priority(with_en) - calculate_game_priority(base) == 180
|
||||
|
||||
def test_en_stacks_with_pp(self, mocker):
|
||||
mocker.patch(
|
||||
@@ -362,12 +362,12 @@ class TestEmptyNetBonus:
|
||||
},
|
||||
)
|
||||
delta = calculate_game_priority(with_both) - calculate_game_priority(base)
|
||||
# PP late P3 = 150, EN late P3 = 200, total = 350
|
||||
assert delta == 350
|
||||
# PP late P3 = 90, EN late P3 = 140, total = 230
|
||||
assert delta == 230
|
||||
|
||||
|
||||
class TestMultiManAdvantage:
|
||||
def test_5v3_ot_pp_bonus_is_320(self, mocker):
|
||||
def test_5v3_ot_pp_bonus_is_180(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -384,7 +384,8 @@ class TestMultiManAdvantage:
|
||||
"situationCode": "1351",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 320
|
||||
# OT PP 5-on-3: 120 * 1.5 = 180
|
||||
assert calculate_game_priority(with_5v3) - calculate_game_priority(base) == 180
|
||||
|
||||
def test_standard_5v4_unchanged(self, mocker):
|
||||
mocker.patch(
|
||||
@@ -403,7 +404,8 @@ class TestMultiManAdvantage:
|
||||
"situationCode": "1451",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
|
||||
# OT PP 5-on-4: 120 base, no advantage mult
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 120
|
||||
|
||||
|
||||
class TestCalculateGamePriority:
|
||||
@@ -544,18 +546,19 @@ class TestCalculateGamePriority:
|
||||
one_goal = self._live_game(home_score=2, away_score=1)
|
||||
assert calculate_game_priority(tied) > calculate_game_priority(one_goal)
|
||||
|
||||
def test_5_4_same_priority_as_1_0(self, mocker):
|
||||
def test_5_4_beats_1_0_via_high_scoring_bonus(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
)
|
||||
high_scoring = self._live_game(home_score=5, away_score=4)
|
||||
low_scoring = self._live_game(home_score=1, away_score=0)
|
||||
assert calculate_game_priority(high_scoring) == calculate_game_priority(
|
||||
# Same 1-goal diff, but 9 total goals earns the high-scoring bonus
|
||||
assert calculate_game_priority(high_scoring) > calculate_game_priority(
|
||||
low_scoring
|
||||
)
|
||||
|
||||
def test_pp_in_ot_adds_200(self, mocker):
|
||||
def test_pp_in_ot_adds_120(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -571,9 +574,9 @@ class TestCalculateGamePriority:
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 120
|
||||
|
||||
def test_pp_late_p3_adds_150(self, mocker):
|
||||
def test_pp_late_p3_adds_90(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -589,9 +592,9 @@ class TestCalculateGamePriority:
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 150
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 90
|
||||
|
||||
def test_pp_mid_p3_adds_100(self, mocker):
|
||||
def test_pp_mid_p3_adds_60(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -607,9 +610,9 @@ class TestCalculateGamePriority:
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 100
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 60
|
||||
|
||||
def test_pp_early_p3_adds_50(self, mocker):
|
||||
def test_pp_early_p3_adds_35(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -625,9 +628,9 @@ class TestCalculateGamePriority:
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 50
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 35
|
||||
|
||||
def test_pp_p1_adds_30(self, mocker):
|
||||
def test_pp_p1_adds_20(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
||||
@@ -643,7 +646,7 @@ class TestCalculateGamePriority:
|
||||
"timeRemaining": "1:30",
|
||||
},
|
||||
)
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 30
|
||||
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 20
|
||||
|
||||
def test_time_priority_increases_as_clock_runs(self, mocker):
|
||||
mocker.patch(
|
||||
@@ -676,25 +679,25 @@ class TestGetComebackBonus:
|
||||
assert get_comeback_bonus(game) == 0
|
||||
|
||||
def test_two_goal_recovery_in_p3(self):
|
||||
# Was 0-2, now 2-2: recovery=2, base=60, period_mult=1.0, tie=30
|
||||
# Was 0-2, now 2-2: recovery=2, base=50, period_mult=1.0, tie=20
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, period=3)
|
||||
assert get_comeback_bonus(game) == 90 # 60*1.0 + 30
|
||||
assert get_comeback_bonus(game) == 70 # 50*1.0 + 20
|
||||
|
||||
def test_three_goal_recovery_in_p3(self):
|
||||
# Was 0-3, now 3-3: recovery=3, base=120, period_mult=1.0, tie=30
|
||||
# Was 0-3, now 3-3: recovery=3, base=90, period_mult=1.0, tie=20
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 3)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||
game = make_game(home_score=3, away_score=3, period=3)
|
||||
assert get_comeback_bonus(game) == 150 # 120*1.0 + 30
|
||||
assert get_comeback_bonus(game) == 110 # 90*1.0 + 20
|
||||
|
||||
def test_partial_recovery_in_p3(self):
|
||||
# Was 0-3, now 2-3: recovery=2, base=60, period_mult=1.0, no tie
|
||||
# Was 0-3, now 2-3: recovery=2, base=50, period_mult=1.0, no tie
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 3
|
||||
game = make_game(home_score=2, away_score=3, period=3)
|
||||
assert get_comeback_bonus(game) == 60 # 60*1.0
|
||||
assert get_comeback_bonus(game) == 50 # 50*1.0
|
||||
|
||||
def test_bonus_persists_across_polls(self):
|
||||
# Set up a 2-goal recovery, then call again — bonus stays
|
||||
@@ -703,21 +706,21 @@ class TestGetComebackBonus:
|
||||
game = make_game(home_score=2, away_score=2, period=3)
|
||||
first = get_comeback_bonus(game)
|
||||
second = get_comeback_bonus(game)
|
||||
assert first == second == 90
|
||||
assert first == second == 70
|
||||
|
||||
def test_period_multiplier_p1_lower(self):
|
||||
# P1 recovery is less dramatic: base=60, period_mult=0.6, tie=30
|
||||
# P1 recovery is less dramatic: base=50, period_mult=0.6, tie=20
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, period=1)
|
||||
assert get_comeback_bonus(game) == 66 # int(60*0.6 + 30)
|
||||
assert get_comeback_bonus(game) == 50 # int(50*0.6 + 20)
|
||||
|
||||
def test_ot_multiplier_higher(self):
|
||||
# OT: base=60, period_mult=1.2, tie=30
|
||||
# OT: base=50, period_mult=1.2, tie=20
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 2)
|
||||
app.games._comeback_tracker[("Maple Leafs", "Bruins")] = 2
|
||||
game = make_game(home_score=2, away_score=2, period=4)
|
||||
assert get_comeback_bonus(game) == 102 # int(60*1.2 + 30)
|
||||
assert get_comeback_bonus(game) == 80 # int(50*1.2 + 20)
|
||||
|
||||
def test_no_bonus_in_intermission(self):
|
||||
app.games._score_cache[("Maple Leafs", "Bruins")] = (0, 2)
|
||||
@@ -739,7 +742,103 @@ class TestGetComebackBonus:
|
||||
get_comeback_bonus(make_game(home_score=1, away_score=2, period=2))
|
||||
result = get_comeback_bonus(make_game(home_score=2, away_score=2, period=3))
|
||||
assert app.games._comeback_tracker[key] == 2
|
||||
assert result == 90 # 60*1.0 + 30
|
||||
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 "lead" in g["Series Blurb"]
|
||||
assert g["Series Summary"] == "Game 4 of 7"
|
||||
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:
|
||||
@@ -763,28 +862,31 @@ class TestCalculateGameImportance:
|
||||
|
||||
def test_playoff_game_gets_fallback_importance(self):
|
||||
game = make_game(game_type=3)
|
||||
assert calculate_game_importance(game) == 100
|
||||
assert calculate_game_importance(game) == 60
|
||||
|
||||
def test_playoff_game7_cup_final_is_max(self):
|
||||
game = make_game(
|
||||
game_type=3,
|
||||
series_status={"round": 4, "topSeedWins": 3, "bottomSeedWins": 3},
|
||||
)
|
||||
assert calculate_game_importance(game) == 200
|
||||
# Game 7 Cup Final: series_factor 1.0 * round 1.5 * 100 = 150
|
||||
assert calculate_game_importance(game) == 150
|
||||
|
||||
def test_playoff_elimination_round1(self):
|
||||
game = make_game(
|
||||
game_type=3,
|
||||
series_status={"round": 1, "topSeedWins": 3, "bottomSeedWins": 2},
|
||||
)
|
||||
assert calculate_game_importance(game) == 170
|
||||
# Elimination (3-x): 0.90 * 1.0 * 100 = 90
|
||||
assert calculate_game_importance(game) == 90
|
||||
|
||||
def test_playoff_game1_round1_lowest(self):
|
||||
game = make_game(
|
||||
game_type=3,
|
||||
series_status={"round": 1, "topSeedWins": 0, "bottomSeedWins": 0},
|
||||
)
|
||||
assert calculate_game_importance(game) == 80
|
||||
# Series factor 0.45 * round 1.0 * 100 = 45
|
||||
assert calculate_game_importance(game) == 45
|
||||
|
||||
def test_playoff_later_rounds_more_important(self):
|
||||
series = {"topSeedWins": 2, "bottomSeedWins": 2}
|
||||
@@ -810,7 +912,8 @@ class TestCalculateGameImportance:
|
||||
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||
)
|
||||
game = make_game(game_state="FUT")
|
||||
assert calculate_game_importance(game) == 150
|
||||
# season_weight 1.0 * stakes 1.0 * rivalry 1.4 * 70 = 98
|
||||
assert calculate_game_importance(game) == 98
|
||||
|
||||
def test_same_division_beats_same_conference(self, mocker):
|
||||
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||
@@ -881,9 +984,9 @@ class TestCalculateGameImportance:
|
||||
assert isinstance(result, int)
|
||||
assert result >= 0
|
||||
|
||||
def test_result_never_exceeds_150(self, mocker):
|
||||
def test_result_never_exceeds_100(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||
)
|
||||
assert calculate_game_importance(make_game(game_state="FUT")) <= 150
|
||||
assert calculate_game_importance(make_game(game_state="FUT")) <= 100
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
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 TestRefreshRoundStartDates:
|
||||
def test_no_bracket_returns_none(self, tmp_db):
|
||||
assert playoff_cache.refresh_round_start_dates(2026) is None
|
||||
|
||||
def test_anchors_round_to_earliest_game(self, tmp_db, monkeypatch):
|
||||
playoff_cache._put(
|
||||
playoff_cache.bracket_key(2026),
|
||||
{"series": [{"seriesLetter": "A", "playoffRound": 1}]},
|
||||
)
|
||||
series_payload = {
|
||||
"games": [
|
||||
{"startTimeUTC": "2026-04-19T23:00:00Z"},
|
||||
{"startTimeUTC": "2026-04-18T23:00:00Z"},
|
||||
{"startTimeUTC": "2026-04-21T23:00:00Z"},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: _Resp(series_payload),
|
||||
)
|
||||
|
||||
merged = playoff_cache.refresh_round_start_dates(2026)
|
||||
assert merged == {"1": "2026-04-18"}
|
||||
assert playoff_cache.get_round_start_date(1).isoformat() == "2026-04-18"
|
||||
|
||||
def test_merges_multiple_rounds_min_per_round(self, tmp_db, monkeypatch):
|
||||
playoff_cache._put(
|
||||
playoff_cache.bracket_key(2026),
|
||||
{
|
||||
"series": [
|
||||
{"seriesLetter": "A", "playoffRound": 1},
|
||||
{"seriesLetter": "B", "playoffRound": 1},
|
||||
{"seriesLetter": "I", "playoffRound": 2},
|
||||
]
|
||||
},
|
||||
)
|
||||
payloads = {
|
||||
"A": {"games": [{"startTimeUTC": "2026-04-19T23:00:00Z"}]},
|
||||
"B": {"games": [{"startTimeUTC": "2026-04-18T23:00:00Z"}]},
|
||||
"I": {"games": [{"startTimeUTC": "2026-04-29T23:00:00Z"}]},
|
||||
}
|
||||
|
||||
def fake_get(url, *a, **kw):
|
||||
letter = url.rstrip("/").rsplit("/", 1)[-1].upper()
|
||||
return _Resp(payloads[letter])
|
||||
|
||||
monkeypatch.setattr("app.playoff_cache.requests.get", fake_get)
|
||||
|
||||
merged = playoff_cache.refresh_round_start_dates(2026)
|
||||
assert merged == {"1": "2026-04-18", "2": "2026-04-29"}
|
||||
|
||||
def test_preserves_existing_rounds_on_merge(self, tmp_db, monkeypatch):
|
||||
playoff_cache._put(
|
||||
playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18", "2": "2026-04-29"}
|
||||
)
|
||||
playoff_cache._put(
|
||||
playoff_cache.bracket_key(2026),
|
||||
{"series": [{"seriesLetter": "M", "playoffRound": 3}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: _Resp(
|
||||
{"games": [{"startTimeUTC": "2026-05-15T23:00:00Z"}]}
|
||||
),
|
||||
)
|
||||
|
||||
merged = playoff_cache.refresh_round_start_dates(2026)
|
||||
assert merged["1"] == "2026-04-18"
|
||||
assert merged["2"] == "2026-04-29"
|
||||
assert merged["3"] == "2026-05-15"
|
||||
|
||||
|
||||
class TestDayNForRound:
|
||||
def test_no_round_num(self, tmp_db):
|
||||
assert playoff_cache.day_n_for_round(None) is None
|
||||
|
||||
def test_round_not_anchored(self, tmp_db):
|
||||
assert playoff_cache.day_n_for_round(1) is None
|
||||
|
||||
def test_day_one(self, tmp_db):
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
||||
now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(1, now=now) == 1
|
||||
|
||||
def test_day_two(self, tmp_db):
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
||||
now = datetime(2026, 4, 19, 10, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(1, now=now) == 2
|
||||
|
||||
def test_day_five(self, tmp_db):
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"})
|
||||
now = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(1, now=now) == 5
|
||||
|
||||
def test_round_two_resets_to_day_one(self, tmp_db):
|
||||
playoff_cache._put(
|
||||
playoff_cache.ROUND_DATES_KEY,
|
||||
{"1": "2026-04-18", "2": "2026-04-29"},
|
||||
)
|
||||
now = datetime(2026, 4, 29, 20, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(2, now=now) == 1
|
||||
|
||||
def test_before_start_returns_none(self, tmp_db):
|
||||
playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"2": "2026-04-29"})
|
||||
now = datetime(2026, 4, 20, 20, 0, tzinfo=EASTERN)
|
||||
assert playoff_cache.day_n_for_round(2, now=now) is None
|
||||
|
||||
|
||||
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}
|
||||
|
||||
|
||||
def _raw_playoff_game(game_id, series_letter="A", game_number=None):
|
||||
"""Minimal raw score-endpoint playoff game for enrichment tests."""
|
||||
game = {
|
||||
"id": game_id,
|
||||
"gameType": 3,
|
||||
"gameState": "FUT",
|
||||
"startTimeUTC": "2026-04-25T23:00:00Z",
|
||||
"seriesStatus": {"seriesLetter": series_letter, "round": 1},
|
||||
}
|
||||
if game_number is not None:
|
||||
game["gameNumber"] = game_number
|
||||
return game
|
||||
|
||||
|
||||
class TestEnrichGameNumbers:
|
||||
def test_basic_enrichment(self, tmp_db, monkeypatch):
|
||||
games = [_raw_playoff_game(101), _raw_playoff_game(102)]
|
||||
series_payload = {
|
||||
"games": [
|
||||
{"id": 101, "gameNumber": 3},
|
||||
{"id": 102, "gameNumber": 4},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: _Resp(series_payload),
|
||||
)
|
||||
playoff_cache.enrich_game_numbers(games)
|
||||
assert games[0]["gameNumber"] == 3
|
||||
assert games[1]["gameNumber"] == 4
|
||||
|
||||
def test_skips_games_with_existing_game_number(self, tmp_db, monkeypatch):
|
||||
games = [_raw_playoff_game(101, game_number=2)]
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: called.append(1) or _Resp({"games": []}),
|
||||
)
|
||||
playoff_cache.enrich_game_numbers(games)
|
||||
assert games[0]["gameNumber"] == 2
|
||||
assert len(called) == 0
|
||||
|
||||
def test_skips_non_playoff_games(self, tmp_db, monkeypatch):
|
||||
games = [{"id": 101, "gameType": 2, "gameState": "FUT"}]
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: called.append(1) or _Resp({"games": []}),
|
||||
)
|
||||
playoff_cache.enrich_game_numbers(games)
|
||||
assert "gameNumber" not in games[0]
|
||||
assert len(called) == 0
|
||||
|
||||
def test_graceful_on_cache_miss(self, tmp_db, monkeypatch):
|
||||
import requests as req
|
||||
|
||||
games = [_raw_playoff_game(101)]
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(req.ConnectionError("x")),
|
||||
)
|
||||
playoff_cache.enrich_game_numbers(games)
|
||||
assert "gameNumber" not in games[0]
|
||||
|
||||
def test_handles_missing_id(self, tmp_db, monkeypatch):
|
||||
game = {
|
||||
"gameType": 3,
|
||||
"gameState": "FUT",
|
||||
"startTimeUTC": "2026-04-25T23:00:00Z",
|
||||
"seriesStatus": {"seriesLetter": "A", "round": 1},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"app.playoff_cache.requests.get",
|
||||
lambda *a, **kw: _Resp({"games": [{"id": 101, "gameNumber": 3}]}),
|
||||
)
|
||||
playoff_cache.enrich_game_numbers([game])
|
||||
assert "gameNumber" not in game
|
||||
|
||||
def test_multiple_series(self, tmp_db, monkeypatch):
|
||||
games = [_raw_playoff_game(101, "A"), _raw_playoff_game(201, "B")]
|
||||
payloads = {
|
||||
"a": {"games": [{"id": 101, "gameNumber": 2}]},
|
||||
"b": {"games": [{"id": 201, "gameNumber": 5}]},
|
||||
}
|
||||
|
||||
def fake_get(url, *a, **kw):
|
||||
letter = url.rstrip("/").rsplit("/", 1)[-1]
|
||||
return _Resp(payloads[letter])
|
||||
|
||||
monkeypatch.setattr("app.playoff_cache.requests.get", fake_get)
|
||||
playoff_cache.enrich_game_numbers(games)
|
||||
assert games[0]["gameNumber"] == 2
|
||||
assert games[1]["gameNumber"] == 5
|
||||
@@ -0,0 +1,338 @@
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
def test_pivotal_blurb(self):
|
||||
game = make_playoff_game(top_wins=2, bottom_wins=2)
|
||||
assert "pivotal" in series_blurb(game).lower()
|
||||
|
||||
def test_leader_trailer_blurb(self):
|
||||
game = make_playoff_game(top_wins=2, bottom_wins=1)
|
||||
blurb = series_blurb(game)
|
||||
assert "lead" in blurb
|
||||
assert "2\u20111" 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‑1" in blurb
|
||||
|
||||
def test_final_clincher_falls_through_to_leader_blurb(self):
|
||||
# Post-game seriesStatus (3-0) would trigger the clincher branch, but
|
||||
# the FINAL card is already decided — that stake belongs to Game 4.
|
||||
game = make_playoff_game(
|
||||
top_wins=3,
|
||||
bottom_wins=0,
|
||||
top_abbrev="PHI",
|
||||
bottom_abbrev="PIT",
|
||||
top_is_home=True,
|
||||
game_state="OFF",
|
||||
)
|
||||
blurb = series_blurb(game)
|
||||
assert "close it out" not in blurb
|
||||
assert "lead" in blurb
|
||||
assert "3‑0" 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"]
|
||||
|
||||
def test_no_stake_badge_on_final(self):
|
||||
# Post-game seriesStatus shows is_clincher true, but CLINCHER refers to
|
||||
# the upcoming Game 4, not the completed card.
|
||||
game = make_playoff_game(top_wins=3, bottom_wins=0, game_state="OFF")
|
||||
assert series_badges(game) == ["R1"]
|
||||
|
||||
|
||||
class TestSeriesSummary:
|
||||
def test_opener_summary(self):
|
||||
game = make_playoff_game(top_wins=0, bottom_wins=0, round_num=1)
|
||||
assert series_summary(game) == "Game 1 of 7"
|
||||
|
||||
def test_leader_summary(self):
|
||||
game = make_playoff_game(top_wins=2, bottom_wins=1)
|
||||
assert series_summary(game) == "Game 4 of 7"
|
||||
|
||||
def test_tied_mid_series_summary(self):
|
||||
game = make_playoff_game(top_wins=1, bottom_wins=1)
|
||||
assert series_summary(game) == "Game 3 of 7"
|
||||
|
||||
def test_finished_game_uses_pre_advance_number(self):
|
||||
# Scoreboard payloads don't carry gameNumber. Once a game goes FINAL,
|
||||
# seriesStatus already includes this game's result, so the card's game
|
||||
# number is hi+lo, not hi+lo+1.
|
||||
game = make_playoff_game(top_wins=1, bottom_wins=0, game_state="FINAL")
|
||||
assert series_summary(game) == "Game 1 of 7"
|
||||
|
||||
def test_finished_game_honors_explicit_game_number(self):
|
||||
game = make_playoff_game(
|
||||
top_wins=2, bottom_wins=0, game_state="FINAL", game_number=2
|
||||
)
|
||||
assert series_summary(game) == "Game 2 of 7"
|
||||
|
||||
def test_fut_game_uses_explicit_game_number(self):
|
||||
game = make_playoff_game(
|
||||
top_wins=1, bottom_wins=1, game_state="FUT", game_number=4
|
||||
)
|
||||
assert series_summary(game) == "Game 4 of 7"
|
||||
|
||||
def test_fut_game_without_game_number_uses_fallback(self):
|
||||
game = make_playoff_game(top_wins=1, bottom_wins=1, game_state="FUT")
|
||||
assert series_summary(game) == "Game 3 of 7"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def test_does_not_count_final_games_as_elimination(self):
|
||||
games = [
|
||||
make_playoff_game(
|
||||
top_wins=3, bottom_wins=0, series_letter="A", game_state="OFF"
|
||||
),
|
||||
make_playoff_game(
|
||||
top_wins=3, bottom_wins=1, series_letter="B", game_state="LIVE"
|
||||
),
|
||||
]
|
||||
meta = today_meta(games)
|
||||
# Only the LIVE card counts; the FINAL card describes a completed game.
|
||||
assert meta["elimination_count"] == 1
|
||||
|
||||
def test_does_not_count_final_game7(self):
|
||||
games = [
|
||||
make_playoff_game(
|
||||
top_wins=3, bottom_wins=3, series_letter="A", game_state="OFF"
|
||||
)
|
||||
]
|
||||
meta = today_meta(games)
|
||||
assert meta["game7_count"] == 0
|
||||
+230
-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,232 @@ 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
|
||||
|
||||
@@ -3,9 +3,15 @@ import pytest
|
||||
from app.scheduler import start_scheduler
|
||||
|
||||
|
||||
def _patch_eager(mocker):
|
||||
mocker.patch("app.scheduler.refresh_bracket")
|
||||
mocker.patch("app.scheduler.refresh_round_start_dates")
|
||||
|
||||
|
||||
class TestStartScheduler:
|
||||
def test_registers_standings_refresh_every_600_seconds(self, mocker):
|
||||
mock_schedule = mocker.patch("app.scheduler.schedule")
|
||||
_patch_eager(mocker)
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
@@ -16,6 +22,7 @@ class TestStartScheduler:
|
||||
|
||||
def test_registers_score_refresh_every_10_seconds(self, mocker):
|
||||
mock_schedule = mocker.patch("app.scheduler.schedule")
|
||||
_patch_eager(mocker)
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
@@ -24,8 +31,53 @@ 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")
|
||||
_patch_eager(mocker)
|
||||
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_registers_round_start_dates_refresh_every_21600_seconds(self, mocker):
|
||||
mock_schedule = mocker.patch("app.scheduler.schedule")
|
||||
_patch_eager(mocker)
|
||||
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 21600 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.refresh_round_start_dates")
|
||||
mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration)
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
start_scheduler()
|
||||
|
||||
assert eager.called
|
||||
|
||||
def test_invokes_round_start_dates_refresh_eagerly_at_startup(self, mocker):
|
||||
mocker.patch("app.scheduler.schedule")
|
||||
mocker.patch("app.scheduler.refresh_bracket")
|
||||
eager = mocker.patch("app.scheduler.refresh_round_start_dates")
|
||||
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")
|
||||
_patch_eager(mocker)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def sleep_twice(_):
|
||||
@@ -42,6 +94,7 @@ class TestStartScheduler:
|
||||
|
||||
def test_continues_after_exception_in_run_pending(self, mocker):
|
||||
mock_schedule = mocker.patch("app.scheduler.schedule")
|
||||
_patch_eager(mocker)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def raise_then_stop(_):
|
||||
|
||||
Reference in New Issue
Block a user