Compare commits

...

36 Commits

Author SHA1 Message Date
josh f99738d2e4 fix: show correct "Game X of 7" for future playoff dates
CI / Lint (push) Successful in 8s
CI / Test (push) Successful in 11s
CI / Build & Push (push) Successful in 20s
Enrich raw score-endpoint games with gameNumber from the series cache
before parsing. The score API omits gameNumber and its seriesStatus
reflects current wins, so all future games in a series computed the
same number. Now we cross-reference by game id against the cached
series-detail endpoint which includes the correct gameNumber per game.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 15:39:44 -04:00
josh 4f5871d119 fix: suppress misleading "Game X of 7" on future-date playoff cards
CI / Lint (push) Successful in 15s
CI / Test (push) Successful in 16s
CI / Build & Push (push) Successful in 33s
FUT games from the score API carry the current seriesStatus, not the
anticipated state, so all future games in a series computed the same
game number. Skip the summary for FUT games; badges and blurbs still
render.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:31:04 -04:00
josh babd199eb3 fix: update Umami script URL to public HTTPS domain
CI / Lint (push) Successful in 9s
CI / Test (push) Successful in 12s
CI / Build & Push (push) Successful in 21s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:44:35 -04:00
josh fa3d315db0 fix: update Umami script URL to Tailscale address
CI / Lint (push) Successful in 9s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 25s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:38:25 -04:00
josh 1bc013e32b feat: add Umami analytics tracking to all pages
CI / Lint (push) Successful in 11s
CI / Test (push) Successful in 13s
CI / Build & Push (push) Successful in 19s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:31:12 -04:00
josh 26678b164b style: fix ruff formatting in bracket_view and routes
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 14s
CI / Build & Push (push) Successful in 30s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:22:56 -04:00
josh 2da60e27ae feat: add 10 UX improvements from interface review
CI / Lint (push) Failing after 10s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
- Stale data banner after 3 consecutive fetch failures, auto-clears on recovery
- Date navigation with left/right arrows (Yesterday/Today/Tomorrow labels),
  fetches from NHL API for non-today dates, disables auto-refresh on history
- Empty state message when no games are scheduled
- Series detail page auto-refreshes every 30s when a game is live
- Notification permission deferred until a playoff OT actually occurs
- Scroll position saved/restored when navigating to/from series detail
- Team records rendered with better contrast and tabular nums
- Active bracket round highlighted with gold heading + underline,
  completed rounds dimmed more aggressively, mobile accordion auto-opens
  current round
- Browser tab title shows live game count (e.g. "NHL Scoreboard (3 Live)")
- Service worker update shows a dismissable toast instead of force-reloading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:22:03 -04:00
josh 58b27ddd20 style: strip trailing periods from all series blurbs
CI / Lint (push) Successful in 13s
CI / Test (push) Successful in 16s
CI / Build & Push (push) Successful in 15s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 12:21:23 -04:00
josh b8819167f5 fix: remove unused variable g from series_blurb after Game X removal
CI / Lint (push) Successful in 13s
CI / Test (push) Successful in 14s
CI / Build & Push (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 12:11:49 -04:00
josh fac1a0ecbc style: drop "— Game X" from all series blurbs
CI / Lint (push) Failing after 5s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
The game number is redundant with the series summary already shown on
the card. Blurbs now read cleaner: "Win-or-go-home." instead of
"Win-or-go-home — Game 7."

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 12:07:16 -04:00
josh c95bea879d feat: enforce 85% test coverage in CI and fix cross-platform strftime bug
CI / Lint (push) Successful in 11s
CI / Test (push) Successful in 13s
CI / Build & Push (push) Successful in 19s
Add pyproject.toml with pytest-cov config so CI fails if coverage drops
below 85%. Fix series_view _format_start using Linux-only %-I/%-d codes
that crash on Windows — use manual formatting instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:53:22 -04:00
josh 4e5fab654d fix: don't label a FINAL playoff card as CLINCHER — those stakes belong to the next game
CI / Lint (push) Successful in 11s
CI / Test (push) Successful in 14s
CI / Build & Push (push) Successful in 52s
seriesStatus updates with the just-played game's win, so once a card goes
FINAL the is_clincher / is_game7 / is_pivotal predicates point at the
upcoming game. Gate the stake badge, stake blurb, and elimination_count
tally on a non-FINAL gameState so a completed Game 3 that left the series
3-0 reads "Flyers lead 3-0" instead of "Flyers can close it out — Game 3."

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:35:32 -04:00
josh c7ba334bb9 fix: freeze PP clock on any stoppage, not just intermission
CI / Lint (push) Successful in 10s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 31s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 21:17:12 -04:00
josh dd1d9fe42d style: bump SOG legibility — bigger, brighter, with tabular nums; match PP size
CI / Lint (push) Successful in 11s
CI / Test (push) Successful in 13s
CI / Build & Push (push) Successful in 26s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 20:20:18 -04:00
josh 7d1649d278 feat: cache-control overhaul so visual changes propagate immediately
CI / Lint (push) Successful in 14s
CI / Test (push) Successful in 12s
CI / Build & Push (push) Successful in 32s
Per-file content-hash versioning on every /static reference, immutable cache
headers on versioned URLs, no-cache on HTML, auto-bumped service worker cache
name with stale-while-revalidate for assets, and a controllerchange listener
that silently reloads the page when a new SW takes control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 20:11:36 -04:00
josh aaa0899506 style: scale up the bracket on wide displays so it actually fills the screen
CI / Lint (push) Successful in 8s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 21s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 19:43:14 -04:00
josh 9b8b671e24 fix: even out the two team columns on the series page after dropping the middle versus block
CI / Lint (push) Successful in 6s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 27s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 19:22:57 -04:00
josh 303db46cc3 refactor: simplify series page — drop redundant versus/headline, blank out placeholder scores, make round badge the bracket link
CI / Lint (push) Successful in 10s
CI / Test (push) Successful in 16s
CI / Build & Push (push) Successful in 32s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 19:18:41 -04:00
josh dc3bfd13b3 style: add soft white glow to team logos so dark crests (TBL) read on dark cards
CI / Lint (push) Successful in 12s
CI / Test (push) Successful in 20s
CI / Build & Push (push) Successful in 26s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 19:01:15 -04:00
josh e0a1c033cf fix: freeze PP clock during intermission so it stops ticking toward zero
CI / Lint (push) Successful in 16s
CI / Test (push) Successful in 11s
CI / Build & Push (push) Successful in 25s
tickClocks iterates every [data-seconds][data-received-at] element. Rendering the PP indicator with those attrs during an intermission made the clock bleed seconds even though play is paused and the penalty isn't running. Drop the ticking attrs when game['Intermission'] is true — render a plain static "PP MM:SS" that resumes ticking on the next live payload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:51:12 -04:00
josh b5ab318e05 fix: drop game_number by one for finished playoff cards
CI / Lint (push) Successful in 8s
CI / Test (push) Successful in 11s
CI / Build & Push (push) Successful in 19s
Scoreboard payloads don't include gameNumber, so the previous fix fell straight through to series_state['game_number'], which is hi+lo+1 — the *next* game. Once a game goes FINAL the win for this game is already banked in seriesStatus, so the card's own number is hi+lo. For the just-played Game 1 of Kings/Avalanche (series now 1-0), this brings the header back to "Game 1 of 7" instead of "Game 2 of 7".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:47:50 -04:00
josh 9eb8a8534a refactor: move power play indicator onto the team row, drop the team name
CI / Lint (push) Successful in 11s
CI / Test (push) Successful in 12s
CI / Build & Push (push) Successful in 27s
The header PP badge repeated the team name that was already visible a row below. Put the indicator on the team that actually has the advantage: "PP 01:47" appears inline next to SOG, and the old ppBadge + .badge-pp styling go away.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:45:14 -04:00
josh e908139323 fix: pin playoff card to its own gameNumber so the header doesn't tick forward after a final
CI / Lint (push) Successful in 10s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 23s
series_state derives game_number from topSeedWins + bottomSeedWins + 1, which becomes the *next* game's number once the API advances seriesStatus post-final. The card for the just-finished game would then read "Game N+1 of 7". Prefer the raw gameNumber on the game payload, falling back to the derived value when it's missing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:41:04 -04:00
josh 13bb90b52b chore: gitignore .claude/ to keep local Claude Code settings out of the tree
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 11s
CI / Build & Push (push) Successful in 21s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:37:32 -04:00
josh 0f1c558493 style: tighten playoff card copy — uniform Game X of 7 header, leaner blurbs
CI / Lint (push) Successful in 10s
CI / Test (push) Successful in 11s
CI / Build & Push (push) Successful in 23s
Top row now always reads "Game N of 7" (round is already shown by the R1/R2/CONF FINAL/CUP FINAL badge). Bottom blurb drops the trailing period on "Series opener" and uses "{Team} lead X-Y" (plural verb, no "— Game N" suffix) when a leader is established.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:36:11 -04:00
josh f1e11a2dc4 style: tighten playoff banner, series, and bracket at ≤480px
CI / Lint (push) Successful in 9s
CI / Test (push) Successful in 9s
CI / Build & Push (push) Successful in 21s
Adds a consolidated narrow-phone breakpoint so the playoff UI reads
cleanly on 375–428px viewports: banner meta stacks vertically (killing
the dangling dot separator), title and trophy shrink, series hero padding
and logos tighten, and the bracket accordion's matchup rows get more
breathing room.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 14:38:53 -04:00
josh 5cdcb2a319 style: soften playoff UI edges and harmonize banner
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 16s
CI / Build & Push (push) Successful in 15s
Swap border-image for the gradient-border trick on the banner and playoff
cards so rounded corners survive. Retone banner bg to a soft radial over
charcoal, replace the hard gold underline with a fading divider, normalize
radii (badges 6px, banner link 10px), add card depth + hover lift, and
rebalance gold usage toward the muted cup-gold-1 tone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:48:06 -04:00
josh 8468655bcf refactor: make the whole playoff banner clickable instead of a Bracket pill
CI / Lint (push) Successful in 8s
CI / Test (push) Successful in 8s
CI / Build & Push (push) Successful in 14s
Wrap the trophy + title + meta group in the bracket link itself so the
whole Stanley Cup Playoffs element doubles as the entry point. Cleaner
banner, one fewer control to style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:15:17 -04:00
josh 64b2e4b5e1 refactor: auto-prompt for notification permission, drop OT alerts button
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 19s
Browsers already gate Notification.requestPermission behind a native
prompt, so a dedicated button was redundant UI clutter. Prompting on
load (only when permission state is "default") keeps the flow and
clears space in the banner for future notification types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:12:01 -04:00
josh 4ea6b87326 style: center trophy + title + meta within playoff banner
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 14s
Switch the banner to a 3-column grid (1fr auto 1fr) so the main group
sits truly centered regardless of the right-side action cluster width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:08:27 -04:00
josh a88e2edef0 fix: anchor Day N to each round's first game instead of lazy first sighting
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 9s
CI / Build & Push (push) Successful in 19s
The banner read "Day 1 of ~60" on day 2 of the playoffs because the old
anchor recorded whatever date we first polled a playoff game as Day 1.
Now round start dates come from /v1/schedule/playoff-series, so Day N
is authoritative and resets at each round boundary. Drops the noisy
"of ~60" denominator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:03:08 -04:00
josh 930247b32f style: apply ruff format and fix lint issues in playoff modules
CI / Lint (push) Successful in 8s
CI / Test (push) Successful in 10s
CI / Build & Push (push) Successful in 22s
- Rename single-letter `l` loop variables in bracket_view to satisfy E741
- Drop unused `json` import from test_playoff_cache (F401/F811)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 12:48:42 -04:00
josh ebe770fecd feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
CI / Lint (push) Failing after 8s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
Turn a regular-season-looking Tuesday into a full playoff experience:

- Playoff banner with round + day + series + elimination counts, gold/silver
  Cup theme toggled by body.playoff-mode
- Series context on each playoff card: round chip, series score, stake badges
  (GAME 7, CLINCHER, PIVOTAL), and one-line blurb
- Game 7s pin to a new Spotlight section above Live
- Playoff OT renders with SUDDEN DEATH badge and pulsing gold border
- Client-side OT notifications via bell button in the banner
- New /series/<id> drill-down with headline, next-game, and game-by-game history
- New /bracket page: 7-column desktop grid, accordion on mobile
- Day N banner count auto-anchors on first playoff scoreboard hit
- SQLite cache for bracket + per-series schedules, stale-on-failure up to 24h

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 12:47:31 -04:00
josh e0db8f0859 refactor: recalibrate hype scoring to deflate gauge and add momentum signals
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 15s
Unify overlapping late-P3 bonuses into a single score-state lookup, add
high-scoring and goal-spike signals, and tighten every component ceiling so
filling the hype bar is reserved for genuinely rare moments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 11:26:07 -04:00
josh 108b77ed39 feat: inline power play indicator as compact badge in card header
CI / Lint (push) Successful in 45s
CI / Test (push) Successful in 8s
CI / Build & Push (push) Successful in 1m8s
Moves the PP team + countdown into the badges row next to period and
clock, freeing up a full line of vertical space on each live card.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 11:05:38 -04:00
josh 61202b2a70 feat: sort scheduled games by start time instead of hype
Pre-game listings are more intuitive in chronological order than ranked
by pregame importance. LIVE and FINAL keep sorting by Priority desc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 11:05:35 -04:00
26 changed files with 4408 additions and 341 deletions
+1 -1
View File
@@ -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
+2
View File
@@ -6,4 +6,6 @@ nhl_standings.db
**/__pycache__
.venv/
.coverage
htmlcov/
.pytest_cache/
.claude/
+71 -1
View File
@@ -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
+160
View File
@@ -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
+207 -140
View File
@@ -4,6 +4,17 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app.config import DB_PATH
from app.playoff import (
is_pinned,
is_playoff_game,
is_playoff_ot,
ot_label,
series_badges,
series_blurb,
series_id,
series_state,
series_summary,
)
EASTERN = ZoneInfo("America/New_York")
@@ -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[
@@ -76,6 +91,7 @@ def parse_games(scoreboard_data):
"total": total_priority,
},
"Start Time": get_start_time(game),
"Start Time UTC": game.get("startTimeUTC", ""),
"Home Record": format_record(game["homeTeam"]["record"])
if game["gameState"] in ["PRE", "FUT"]
else "N/A",
@@ -95,24 +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,
}
)
# Sort games based on priority
return sorted(extracted_info, key=lambda x: x["Priority"], reverse=True)
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 (pin_rank, 0, g["Start Time UTC"], 0)
# LIVE / FINAL — highest priority first
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"]
@@ -120,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):
@@ -203,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,
@@ -218,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"]) + (
@@ -245,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,
)
@@ -362,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,
@@ -421,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)
@@ -441,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,
@@ -462,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"):
@@ -475,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"]
@@ -510,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
View File
@@ -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"
+295
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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:
+179
View File
@@ -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 -40
View File
@@ -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} &middot; SUDDEN DEATH</span>`
: `<span class="badge badge-live">${periodText}</span>`;
const dot = running ? `<span class="live-dot"></span>` : '';
// Tick the clock locally when the clock is running or during intermission
const shouldTick = running || intermission;
const rawSeconds = timeToSeconds(time);
const clockAttrs = shouldTick
@@ -63,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>
</div>
${dot}
</div>
${teamRow(game, 'Away', 'live')}
${teamRow(game, 'Home', 'live')}
${ppIndicator(game)}
${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,29 +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 ppIndicator(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 `
<div class="pp-indicator">
<span class="pp-label">PP</span>
<span class="pp-team">${team}</span>
<span class="pp-clock" ${attrs}>${timeStr}</span>
</div>`;
function gameKey(game) {
return `${game['Away Team']}|${game['Home Team']}`;
}
// ── Gauge ────────────────────────────────────────────
@@ -202,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;
@@ -231,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 -47
View File
File diff suppressed because it is too large Load Diff
-49
View File
@@ -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;
});
})
);
});
+21
View File
@@ -0,0 +1,21 @@
{% if m.empty %}
<div class="bracket-matchup bracket-matchup-empty">
<div class="bracket-team bracket-team-placeholder">TBD</div>
<div class="bracket-team bracket-team-placeholder">TBD</div>
</div>
{% else %}
<a class="bracket-matchup bracket-matchup-{{ m.state }}" href="/series/{{ m.series_id }}">
<div class="bracket-team {% if m.winner_abbrev == m.top.abbrev %}bracket-team-winner{% endif %}">
{% if m.top.logo %}<img class="bracket-team-logo" src="{{ m.top.logo }}" alt="{{ m.top.abbrev }}">{% endif %}
<span class="bracket-team-abbrev">{{ m.top.abbrev }}</span>
{% if m.top.seed %}<span class="bracket-team-seed">{{ m.top.seed }}</span>{% endif %}
<span class="bracket-team-wins">{{ m.top_wins }}</span>
</div>
<div class="bracket-team {% if m.winner_abbrev == m.bottom.abbrev %}bracket-team-winner{% endif %}">
{% if m.bottom.logo %}<img class="bracket-team-logo" src="{{ m.bottom.logo }}" alt="{{ m.bottom.abbrev }}">{% endif %}
<span class="bracket-team-abbrev">{{ m.bottom.abbrev }}</span>
{% if m.bottom.seed %}<span class="bracket-team-seed">{{ m.bottom.seed }}</span>{% endif %}
<span class="bracket-team-wins">{{ m.bottom_wins }}</span>
</div>
</a>
{% endif %}
+83
View File
@@ -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="/">&larr; NHL Scoreboard</a>
</header>
<main class="bracket-main">
<section class="bracket-hero">
<h1 class="bracket-title">{{ bracket.year }} Stanley Cup Playoffs</h1>
<div class="bracket-subtitle">The road to 16 wins</div>
</section>
{# Desktop: 7-column grid (East R1 | R2 | CF | Cup | CF | R2 | R1 West) #}
<section class="bracket-grid" aria-label="Full playoff bracket">
<div class="bracket-col bracket-col-r1 bracket-col-east">
<h2 class="bracket-col-heading {% 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>
+48 -4
View File
@@ -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">&larr;</button>
<span id="date-label" class="date-label"></span>
<button id="date-next" class="date-btn" aria-label="Next day">&rarr;</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 &mdash; 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 &middot; Game 7</h2>
<div id="pinned-games-section" class="games-grid"></div>
</section>
<section id="live-section" class="section hidden">
<h2 class="section-heading">Live</h2>
<div id="live-games-section" class="games-grid"></div>
@@ -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>
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ series.top.abbrev }} vs {{ series.bottom.abbrev }} &middot; {{ series.round_label }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="{{ static_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="/">&larr; 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 %} &middot; {{ 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 %} &middot; {{ 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 &middot; Game {{ series.next_game.game_number }}</h2>
<div class="series-next-card">
<div class="series-next-matchup">
<span class="series-next-team">{{ series.next_game.away.abbrev }}</span>
<span class="series-next-at">@</span>
<span class="series-next-team">{{ series.next_game.home.abbrev }}</span>
</div>
<div class="series-next-meta">
{% if series.next_game.start_date %}{{ series.next_game.start_date }}{% endif %}
{% if series.next_game.start_local %} &middot; {{ series.next_game.start_local }}{% endif %}
{% if series.next_game.venue %} &middot; {{ series.next_game.venue }}{% endif %}
{% if series.next_game.if_necessary %} &middot; <em>if necessary</em>{% endif %}
</div>
</div>
</section>
{% endif %}
<section class="series-history">
<h2 class="section-heading">Games</h2>
<ol class="series-games">
{% for game in series.games %}
<li class="series-game series-game-{{ game.state_group }}">
<div class="series-game-col-number">Game {{ game.game_number }}{% if game.if_necessary and game.state_group != 'completed' %}*{% endif %}</div>
<div class="series-game-col-matchup">
<div class="series-game-team">
<span class="series-game-abbrev">{{ game.away.abbrev }}</span>
<span class="series-game-score {% if game.winner_abbrev == game.away.abbrev %}series-game-winner{% endif %}">
{% if game.away.score is not none %}{{ game.away.score }}{% 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 %} &middot; {{ 'OT' if not game.ended_in_multi_ot else 'Multi-OT' }}{% endif %}</span>
{% else %}
<span class="series-game-state">
{% if game.start_date %}{{ game.start_date }}{% endif %}
{% if game.start_local %} &middot; {{ game.start_local }}{% endif %}
</span>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</section>
</main>
</body>
</html>
+60
View File
@@ -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;
})
);
});
+15
View File
@@ -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__.",
]
+53
View File
@@ -19,6 +19,8 @@ def make_game(
game_type=2,
situation=None,
series_status=None,
home_abbrev="TOR",
away_abbrev="BOS",
):
clock = {
"timeRemaining": f"{seconds_remaining // 60:02d}:{seconds_remaining % 60:02d}",
@@ -33,6 +35,7 @@ def make_game(
"clock": clock,
"homeTeam": {
"name": {"default": home_name},
"abbrev": home_abbrev,
"score": home_score,
"sog": 15,
"logo": "https://example.com/home.png",
@@ -40,6 +43,7 @@ def make_game(
},
"awayTeam": {
"name": {"default": away_name},
"abbrev": away_abbrev,
"score": away_score,
"sog": 12,
"logo": "https://example.com/away.png",
@@ -52,6 +56,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
+135
View File
@@ -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]
+263 -42
View File
@@ -1,5 +1,5 @@
import app.games
from tests.conftest import make_game
from tests.conftest import make_game, make_playoff_game
from app.games import (
_get_man_advantage,
calculate_game_importance,
@@ -123,6 +123,124 @@ class TestParseGames:
def test_returns_empty_list_for_empty_dict(self):
assert parse_games({}) == []
def test_pre_games_sorted_by_start_time_ascending(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={
"league_sequence": 16,
"league_l10_sequence": 16,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 40,
"wildcard_sequence": 16,
},
)
late = make_game(
game_state="FUT",
home_name="Rangers",
away_name="Devils",
start_time_utc="2024-04-10T22:00:00Z",
)
early = make_game(
game_state="FUT",
home_name="Bruins",
away_name="Canadiens",
start_time_utc="2024-04-10T19:00:00Z",
)
next_day = make_game(
game_state="FUT",
home_name="Kings",
away_name="Ducks",
start_time_utc="2024-04-11T00:30:00Z",
)
result = parse_games({"games": [late, next_day, early]})
pre_games = [g for g in result if g["Game State"] == "PRE"]
names = [g["Home Team"] for g in pre_games]
assert names == ["Bruins", "Rangers", "Kings"]
def test_live_games_still_sorted_by_priority_descending(self, mocker):
mocker.patch(
"app.games.get_team_standings",
return_value={
"league_sequence": 16,
"league_l10_sequence": 16,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 40,
"wildcard_sequence": 16,
},
)
# Late P3 tied — high priority
high = make_game(
game_state="LIVE",
home_name="Rangers",
away_name="Devils",
home_score=2,
away_score=2,
period=3,
seconds_remaining=60,
)
# Early P1 blowout — low priority
low = make_game(
game_state="LIVE",
home_name="Bruins",
away_name="Canadiens",
home_score=5,
away_score=0,
period=1,
seconds_remaining=900,
)
result = parse_games({"games": [low, high]})
live_games = [g for g in result if g["Game State"] == "LIVE"]
assert live_games[0]["Home Team"] == "Rangers"
assert live_games[1]["Home Team"] == "Bruins"
def test_pre_games_ignore_priority_even_if_nonzero(self, mocker):
# Give one team standings that maximize importance, another that minimize it
def fake_standings(name):
if name == "Bruins":
return {
"league_sequence": 1,
"league_l10_sequence": 1,
"division_abbrev": "ATL",
"conference_abbrev": "E",
"games_played": 80,
"wildcard_sequence": 18,
}
return {
"league_sequence": 32,
"league_l10_sequence": 32,
"division_abbrev": "PAC",
"conference_abbrev": "W",
"games_played": 10,
"wildcard_sequence": 30,
}
mocker.patch("app.games.get_team_standings", side_effect=fake_standings)
# Bruins game starts later but will have higher importance
high_hype_late = make_game(
game_state="FUT",
home_name="Bruins",
away_name="Maple Leafs",
start_time_utc="2024-04-10T23:00:00Z",
)
low_hype_early = make_game(
game_state="FUT",
home_name="Kings",
away_name="Ducks",
start_time_utc="2024-04-10T19:00:00Z",
)
result = parse_games({"games": [high_hype_late, low_hype_early]})
pre_games = [g for g in result if g["Game State"] == "PRE"]
# Lower-hype-but-earlier game must still appear first
assert pre_games[0]["Home Team"] == "Kings"
assert pre_games[1]["Home Team"] == "Bruins"
assert pre_games[1]["Priority"] > pre_games[0]["Priority"]
class TestGetPowerPlayInfo:
def test_returns_empty_when_no_situation(self):
@@ -189,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},
@@ -207,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},
@@ -225,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(
@@ -244,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},
@@ -266,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(
@@ -285,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:
@@ -426,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},
@@ -453,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},
@@ -471,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},
@@ -489,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},
@@ -507,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},
@@ -525,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(
@@ -558,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
@@ -585,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)
@@ -621,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:
@@ -645,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}
@@ -692,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")
@@ -763,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
+370
View File
@@ -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
+338
View File
@@ -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 "11" 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 "30" 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
View File
@@ -1,6 +1,6 @@
import json
from tests.conftest import make_game
from tests.conftest import make_game, make_playoff_game
class TestIndexRoute:
@@ -86,3 +86,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
+53
View File
@@ -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(_):