feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
CI / Lint (push) Failing after 8s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped

Turn a regular-season-looking Tuesday into a full playoff experience:

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 12:47:31 -04:00
parent e0db8f0859
commit ebe770fecd
21 changed files with 3163 additions and 31 deletions
+132
View File
@@ -0,0 +1,132 @@
"""Normalize NHL /v1/playoff-bracket payloads for the bracket template.
The NHL bracket uses stable series letters:
A,B,C,D = Round 1 East E,F,G,H = Round 1 West
I,J = Round 2 East K,L = Round 2 West
M = Conf Final East N = Conf Final West
O = Stanley Cup Final
"""
from app.playoff import ROUND_LABELS
EAST_R1 = ["A", "B", "C", "D"]
WEST_R1 = ["E", "F", "G", "H"]
EAST_R2 = ["I", "J"]
WEST_R2 = ["K", "L"]
EAST_CF = ["M"]
WEST_CF = ["N"]
CUP_FINAL = ["O"]
def build_bracket_view(year, bracket_payload, fetched_at=None):
"""Shape the raw bracket API payload for bracket.html.
Returns a dict of rounds grouped by conference, plus a flat `matchups` list
keyed by letter for the mobile accordion. Missing letters render as empty
placeholder slots so the grid stays visually complete before upsets decide.
"""
series_by_letter = {}
for s in (bracket_payload or {}).get("series", []):
letter = s.get("seriesLetter")
if letter:
series_by_letter[letter] = s
def slot(letter):
return _matchup(year, letter, series_by_letter.get(letter))
east_r1 = [slot(l) for l in EAST_R1]
west_r1 = [slot(l) for l in WEST_R1]
east_r2 = [slot(l) for l in EAST_R2]
west_r2 = [slot(l) for l in WEST_R2]
east_cf = [slot(l) for l in EAST_CF]
west_cf = [slot(l) for l in WEST_CF]
cup = [slot(l) for l in CUP_FINAL]
return {
"year": year,
"fetched_at": fetched_at,
"bracket_logo": (bracket_payload or {}).get("bracketLogo"),
"east_r1": east_r1,
"west_r1": west_r1,
"east_r2": east_r2,
"west_r2": west_r2,
"east_cf": east_cf,
"west_cf": west_cf,
"cup": cup,
"rounds": [
{"label": ROUND_LABELS[1], "east": east_r1, "west": west_r1},
{"label": ROUND_LABELS[2], "east": east_r2, "west": west_r2},
{"label": ROUND_LABELS[3], "east": east_cf, "west": west_cf},
{"label": ROUND_LABELS[4], "cup": cup},
],
}
def _matchup(year, letter, series):
"""Render-ready dict for one bracket slot. Empty when the series is unknown."""
if not series:
return {
"letter": letter,
"series_id": f"{year}-{letter}",
"empty": True,
"top": None,
"bottom": None,
"top_wins": 0,
"bottom_wins": 0,
"round": None,
"winner_abbrev": None,
"state": "pending",
}
top = series.get("topSeedTeam") or {}
bot = series.get("bottomSeedTeam") or {}
top_wins = _to_int(series.get("topSeedWins"))
bot_wins = _to_int(series.get("bottomSeedWins"))
winning_id = series.get("winningTeamId")
winner_abbrev = None
if winning_id is not None:
if top.get("id") == winning_id:
winner_abbrev = top.get("abbrev")
elif bot.get("id") == winning_id:
winner_abbrev = bot.get("abbrev")
if winner_abbrev:
state = "complete"
elif top_wins > 0 or bot_wins > 0:
state = "active"
else:
state = "upcoming"
return {
"letter": letter,
"series_id": f"{year}-{letter}",
"empty": False,
"top": _team(top, series.get("topSeedRankAbbrev")),
"bottom": _team(bot, series.get("bottomSeedRankAbbrev")),
"top_wins": top_wins,
"bottom_wins": bot_wins,
"round": series.get("playoffRound"),
"winner_abbrev": winner_abbrev,
"state": state,
}
def _team(team, seed_abbrev=None):
if not team:
return None
return {
"id": team.get("id"),
"abbrev": team.get("abbrev"),
"name": (team.get("name") or {}).get("default"),
"common_name": (team.get("commonName") or {}).get("default"),
"logo": team.get("darkLogo") or team.get("logo"),
"seed": seed_abbrev,
}
def _to_int(v, default=0):
try:
return int(v)
except (TypeError, ValueError):
return default
+28 -2
View File
@@ -4,6 +4,17 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from app.config import DB_PATH
from app.playoff import (
is_pinned,
is_playoff_game,
is_playoff_ot,
ot_label,
series_badges,
series_blurb,
series_id,
series_state,
series_summary,
)
EASTERN = ZoneInfo("America/New_York")
@@ -100,15 +111,30 @@ def parse_games(scoreboard_data):
game, game["awayTeam"]["name"]["default"]
),
"Last Period Type": get_game_outcome(game, game_state),
"Is Playoff": is_playoff_game(game),
"Pinned": is_pinned(game),
"Playoff OT": is_playoff_ot(game),
"OT Label": ot_label(game.get("periodDescriptor", {}).get("number", 0))
if is_playoff_ot(game)
else "",
"Series Blurb": series_blurb(game) if is_playoff_game(game) else "",
"Series Summary": series_summary(game) if is_playoff_game(game) else "",
"Series Badges": series_badges(game) if is_playoff_game(game) else [],
"Series State": series_state(game.get("seriesStatus", {}))
if is_playoff_game(game)
else None,
"Series ID": series_id(game) if is_playoff_game(game) else None,
}
)
def _sort_key(g):
# Pinned playoff games (Game 7s) sort first within their state bucket.
pin_rank = 0 if g.get("Pinned") else 1
if g["Game State"] == "PRE":
# Earliest start first — ISO-8601 sorts correctly as a string
return (0, g["Start Time UTC"], 0)
return (pin_rank, 0, g["Start Time UTC"], 0)
# LIVE / FINAL — highest priority first
return (1, "", -g["Priority"])
return (pin_rank, 1, "", -g["Priority"])
return sorted(extracted_info, key=_sort_key)
+272
View File
@@ -0,0 +1,272 @@
from datetime import datetime
from zoneinfo import ZoneInfo
EASTERN = ZoneInfo("America/New_York")
ROUND_LABELS = {
1: "First Round",
2: "Second Round",
3: "Conference Finals",
4: "Stanley Cup Final",
}
def is_playoff_game(game):
return game.get("gameType", game.get("Game Type", 2)) == 3
def series_id(game):
"""Return '{year}-{letter}' for a playoff game, or None if unavailable.
Year is derived from `startTimeUTC` (Eastern) and falls back to current
Eastern year. Letter comes from `seriesStatus.seriesLetter` (or the
legacy `seriesAbbrev` field).
"""
if not is_playoff_game(game):
return None
ss = game.get("seriesStatus") or {}
letter = ss.get("seriesLetter") or ss.get("seriesAbbrev")
if not letter:
return None
start = game.get("startTimeUTC") or ""
try:
year = datetime.fromisoformat(start.replace("Z", "+00:00")).astimezone(
EASTERN
).year
except (ValueError, AttributeError):
year = datetime.now(EASTERN).year
return f"{year}-{letter.upper()}"
def series_state(series_status):
"""Pure function of a raw seriesStatus dict.
Returns a dict of predicates + derived values. When seriesStatus is empty
(playoff game reported before the API has filled in the matchup) the state
degrades gracefully — all predicates False, game_number 1, round 1.
"""
if not series_status:
return {
"round": 1,
"top_wins": 0,
"bottom_wins": 0,
"hi": 0,
"lo": 0,
"leader": None,
"game_number": 1,
"is_game7": False,
"is_clincher": False,
"is_elimination": False,
"is_pivotal": False,
"is_opener": True,
}
round_num = series_status.get("round", 1)
top = series_status.get("topSeedWins", 0)
bot = series_status.get("bottomSeedWins", 0)
hi = max(top, bot)
lo = min(top, bot)
if top > bot:
leader = "top"
elif bot > top:
leader = "bottom"
else:
leader = None
return {
"round": round_num,
"top_wins": top,
"bottom_wins": bot,
"hi": hi,
"lo": lo,
"leader": leader,
"game_number": top + bot + 1,
"is_game7": hi == 3 and lo == 3,
"is_clincher": hi == 3 and lo < 3,
"is_elimination": hi == 3 and lo < 3,
"is_pivotal": hi == 2 and lo == 2,
"is_opener": hi == 0 and lo == 0,
}
def series_blurb(game):
"""One sentence of series context for a playoff card."""
state = series_state(game.get("seriesStatus", {}))
g = state["game_number"]
leader_name = _leader_name(game, state)
trailer_name = _trailer_name(game, state)
if state["is_game7"]:
return "Win-or-go-home \u2014 Game 7."
if state["is_clincher"] and leader_name:
return f"{leader_name} can close it out \u2014 Game {g}."
if state["is_pivotal"]:
return f"Series tied 2\u20112 \u2014 pivotal Game {g}."
if state["is_opener"]:
return "Series opener."
if leader_name and trailer_name:
return f"{leader_name} leads {state['hi']}\u2011{state['lo']} \u2014 Game {g}."
if state["hi"] == state["lo"]:
return f"Series even at {state['hi']}\u2011{state['lo']} \u2014 Game {g}."
return f"Game {g}."
def series_badges(game):
"""Ordered list of stake labels to render as chip-badges on the card."""
state = series_state(game.get("seriesStatus", {}))
badges = []
round_num = state["round"]
round_abbrev = {1: "R1", 2: "R2", 3: "CONF FINAL", 4: "CUP FINAL"}.get(
round_num, f"R{round_num}"
)
badges.append(round_abbrev)
if state["is_game7"]:
badges.append("GAME 7")
elif state["is_clincher"]:
badges.append("CLINCHER")
elif state["is_pivotal"]:
badges.append("PIVOTAL")
return badges
def series_summary(game):
"""Short series-score line rendered under the blurb, e.g. 'LAK leads 2-1'."""
state = series_state(game.get("seriesStatus", {}))
if state["is_opener"]:
return f"Game 1 of 7 \u00b7 Round {state['round']}"
leader_name = _leader_name(game, state)
if leader_name:
return f"{leader_name} leads {state['hi']}\u2011{state['lo']}"
return f"Series tied {state['hi']}\u2011{state['lo']}"
def is_pinned(game):
if not is_playoff_game(game):
return False
state = series_state(game.get("seriesStatus", {}))
if not state["is_game7"]:
return False
gs = game.get("gameState", "")
return gs in ("LIVE", "CRIT", "PRE", "FUT")
def is_playoff_ot(game):
if not is_playoff_game(game):
return False
gs = game.get("gameState", "")
if gs not in ("LIVE", "CRIT"):
return False
period = game.get("periodDescriptor", {}).get("number", 0)
return period >= 4
def ot_label(period):
"""'OT', '2OT', '3OT', ... from raw period number (4 = 1st OT)."""
if period < 4:
return ""
n = period - 3
return "OT" if n == 1 else f"{n}OT"
def today_meta(raw_games, now=None, day_n=None, day_total=None):
"""Build the banner payload from the raw NHL games list.
`raw_games` is the list inside the NHL score response (each with gameType,
seriesStatus, etc.) — NOT the parsed game dicts. This keeps the dependency
one-way: playoff.py doesn't need to know parse_games' field names.
`day_n` / `day_total` are injected by the caller (from the playoff_cache
module) to keep this function pure and test-friendly.
"""
playoff_games = [g for g in raw_games if g.get("gameType") == 3]
playoff_mode = len(playoff_games) > 0
if not playoff_mode:
return {
"playoff_mode": False,
"round_label": None,
"day_n": None,
"day_total": None,
"series_active": 0,
"elimination_count": 0,
"game7_count": 0,
"year": _year(now),
}
series_letters = set()
elim = 0
g7 = 0
max_round = 1
for g in playoff_games:
ss = g.get("seriesStatus") or {}
letter = ss.get("seriesLetter") or ss.get("seriesAbbrev")
if letter:
series_letters.add(letter)
state = series_state(ss)
max_round = max(max_round, state["round"])
if state["is_game7"]:
g7 += 1
elif state["is_clincher"]:
elim += 1
return {
"playoff_mode": True,
"round_label": ROUND_LABELS.get(max_round, f"Round {max_round}"),
"day_n": day_n,
"day_total": day_total,
"series_active": len(series_letters) if series_letters else len(playoff_games),
"elimination_count": elim,
"game7_count": g7,
"year": _year(now),
}
def _year(now):
now = now or datetime.now(EASTERN)
# NHL seasons span two calendar years; the playoff year is the later one.
# April onward = current calendar year; Jan-March = previous year's playoffs
# only if we're still in the prior season, but playoffs start in April, so
# reporting `now.year` is correct during any active playoff window.
return now.year
def _leader_name(game, state):
"""Return the common name of the series-leading team, or None."""
if state["leader"] is None:
return None
top_team = (game.get("seriesStatus") or {}).get("topSeedTeamAbbrev")
bottom_team = (game.get("seriesStatus") or {}).get("bottomSeedTeamAbbrev")
home = game.get("homeTeam", {}).get("name", {}).get("default")
away = game.get("awayTeam", {}).get("name", {}).get("default")
home_abbrev = game.get("homeTeam", {}).get("abbrev")
away_abbrev = game.get("awayTeam", {}).get("abbrev")
leader_abbrev = top_team if state["leader"] == "top" else bottom_team
if leader_abbrev and home_abbrev and leader_abbrev == home_abbrev:
return home
if leader_abbrev and away_abbrev and leader_abbrev == away_abbrev:
return away
# Fallback — the seriesStatus didn't include seed abbreviations. The best
# we can do without the bracket cache is report by seed label.
return "Top seed" if state["leader"] == "top" else "Bottom seed"
def _trailer_name(game, state):
if state["leader"] is None:
return None
top_team = (game.get("seriesStatus") or {}).get("topSeedTeamAbbrev")
bottom_team = (game.get("seriesStatus") or {}).get("bottomSeedTeamAbbrev")
home = game.get("homeTeam", {}).get("name", {}).get("default")
away = game.get("awayTeam", {}).get("name", {}).get("default")
home_abbrev = game.get("homeTeam", {}).get("abbrev")
away_abbrev = game.get("awayTeam", {}).get("abbrev")
trailer_abbrev = bottom_team if state["leader"] == "top" else top_team
if trailer_abbrev and home_abbrev and trailer_abbrev == home_abbrev:
return home
if trailer_abbrev and away_abbrev and trailer_abbrev == away_abbrev:
return away
return "Bottom seed" if state["leader"] == "top" else "Top seed"
+217
View File
@@ -0,0 +1,217 @@
"""Playoff bracket + per-series schedule caching.
Single table `playoff_cache` keyed by arbitrary cache_key. Stale rows are
served on fetch failure up to 24h old, with callers free to check staleness
via the returned `fetched_at` timestamp.
"""
import json
import logging
import re
import sqlite3
import time
from datetime import date, datetime
from zoneinfo import ZoneInfo
import requests
from app.config import DB_PATH
logger = logging.getLogger(__name__)
EASTERN = ZoneInfo("America/New_York")
BRACKET_TTL = 3600 # refresh at this cadence via scheduler
SERIES_TTL = 300 # lazy cache for per-series schedule fetches
MAX_STALE_SECONDS = 86400 # 24h
SERIES_ID_RE = re.compile(r"^(20\d{2})-([A-P])$")
def _connect():
return sqlite3.connect(DB_PATH)
def create_cache_table(conn):
conn.execute(
"""
CREATE TABLE IF NOT EXISTS playoff_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
fetched_at INTEGER NOT NULL
)
"""
)
conn.commit()
def _put(cache_key, payload):
conn = _connect()
try:
create_cache_table(conn)
conn.execute(
"INSERT OR REPLACE INTO playoff_cache (cache_key, payload, fetched_at) "
"VALUES (?, ?, ?)",
(cache_key, json.dumps(payload), int(time.time())),
)
conn.commit()
finally:
conn.close()
def _get(cache_key):
"""Return (payload_dict, fetched_at_unix) or (None, None)."""
conn = _connect()
try:
create_cache_table(conn)
cur = conn.execute(
"SELECT payload, fetched_at FROM playoff_cache WHERE cache_key = ?",
(cache_key,),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return None, None
return json.loads(row[0]), row[1]
# ── Bracket ────────────────────────────────────────────────────────
def bracket_key(year):
return f"bracket:{year}"
def refresh_bracket(year=None):
"""Fetch /v1/playoff-bracket/{year} and store it. Returns payload or None."""
if year is None:
year = datetime.now(EASTERN).year
url = f"https://api-web.nhle.com/v1/playoff-bracket/{year}"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
_put(bracket_key(year), data)
_maybe_record_start_date(data)
return data
except requests.RequestException as e:
logger.warning("Failed to refresh playoff bracket for %s: %s", year, e)
return None
def get_bracket(year=None):
"""Return (bracket_payload, fetched_at) from cache. Never triggers a fetch."""
if year is None:
year = datetime.now(EASTERN).year
payload, fetched = _get(bracket_key(year))
return payload, fetched
# ── Per-series schedule ────────────────────────────────────────────
def series_key(season, letter):
return f"series:{season}:{letter.upper()}"
def parse_series_id(series_id):
"""Parse 'YYYY-L' into (season_str, letter). Returns None on invalid input."""
m = SERIES_ID_RE.match(series_id or "")
if not m:
return None
year, letter = m.group(1), m.group(2)
season = f"{int(year) - 1}{year}"
return season, letter
def fetch_series(series_id):
"""Fetch /v1/schedule/playoff-series/{season}/{letter}. 5-min cache.
Returns the raw API payload or None on both cache miss and fetch failure.
On failure we fall back to stale cache up to 24h old.
"""
parsed = parse_series_id(series_id)
if parsed is None:
return None
season, letter = parsed
key = series_key(season, letter)
payload, fetched = _get(key)
if payload is not None and fetched is not None:
if time.time() - fetched < SERIES_TTL:
return payload
url = f"https://api-web.nhle.com/v1/schedule/playoff-series/{season}/{letter.lower()}"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
_put(key, data)
return data
except requests.RequestException as e:
logger.warning("Failed to fetch series %s: %s", series_id, e)
if payload is not None and fetched is not None:
if time.time() - fetched < MAX_STALE_SECONDS:
return payload
return None
# ── Playoff start date (drives the "Day N" banner) ─────────────────
META_KEY = "meta:first_playoff_date"
def _maybe_record_start_date(bracket_payload):
"""Store the earliest scheduled round-1 series date as Day 1, once.
The bracket endpoint doesn't include individual games — only the series
list. So we record the current day as Day 1 on the first bracket fetch
where any series has nonzero wins OR is otherwise underway. For a perfect
Day-1 anchor we'd need the per-series schedule; the scoreboard-driven
fallback (see record_start_date_if_missing) handles that case.
"""
# No-op for now; the scoreboard-driven path is authoritative.
return None
def record_start_date_if_missing(scoreboard_games, now=None):
"""Called from routes on every /scoreboard hit.
If we haven't seen a playoff game yet and the current scoreboard contains
one, record today (Eastern) as Day 1. Idempotent after first write.
"""
existing, _ = _get(META_KEY)
if existing and existing.get("first_date"):
return existing["first_date"]
has_playoff = any(g.get("gameType") == 3 for g in scoreboard_games)
if not has_playoff:
return None
now = now or datetime.now(EASTERN)
today = now.date().isoformat()
_put(META_KEY, {"first_date": today, "recorded_at_utc": now.astimezone().isoformat()})
return today
def get_playoff_start_date():
payload, _ = _get(META_KEY)
if not payload or not payload.get("first_date"):
return None
try:
return date.fromisoformat(payload["first_date"])
except ValueError:
return None
def day_n(now=None):
"""Returns (day_n, day_total_estimate) or (None, None)."""
start = get_playoff_start_date()
if start is None:
return None, None
now = now or datetime.now(EASTERN)
today = now.date()
n = (today - start).days + 1
if n < 1:
return None, None
# Typical playoff length: ~60 days from first-round start through Cup final.
return n, 60
+55 -5
View File
@@ -1,10 +1,25 @@
import json
from flask import render_template, jsonify, send_from_directory
from flask import abort, render_template, jsonify, send_from_directory
from app import app
from app.config import SCOREBOARD_DATA_FILE
from app.games import parse_games
from app.playoff import today_meta
from app.bracket_view import build_bracket_view
from app.playoff_cache import (
day_n as compute_day_n,
fetch_series,
get_bracket,
parse_series_id,
record_start_date_if_missing,
refresh_bracket,
)
from app.series_view import build_series_view
from datetime import datetime
from zoneinfo import ZoneInfo
_EASTERN = ZoneInfo("America/New_York")
@app.route("/manifest.json")
@@ -45,20 +60,55 @@ def get_scoreboard():
)
if scoreboard_data:
raw_games = scoreboard_data.get("games", [])
record_start_date_if_missing(raw_games)
games = parse_games(scoreboard_data)
n, total = compute_day_n()
meta = today_meta(raw_games, day_n=n, day_total=total)
pinned = [g for g in games if g.get("Pinned")]
remaining = [g for g in games if not g.get("Pinned")]
return jsonify(
{
"meta": meta,
"pinned_games": pinned,
"live_games": [
g
for g in games
for g in remaining
if g["Game State"] == "LIVE" and not g["Intermission"]
],
"intermission_games": [
g for g in games if g["Game State"] == "LIVE" and g["Intermission"]
g
for g in remaining
if g["Game State"] == "LIVE" and g["Intermission"]
],
"pre_games": [g for g in games if g["Game State"] == "PRE"],
"final_games": [g for g in games if g["Game State"] == "FINAL"],
"pre_games": [g for g in remaining if g["Game State"] == "PRE"],
"final_games": [g for g in remaining if g["Game State"] == "FINAL"],
}
)
else:
return jsonify({"error": "Failed to retrieve scoreboard data"})
@app.route("/series/<series_id>")
def series_detail(series_id):
if parse_series_id(series_id) is None:
abort(404)
payload = fetch_series(series_id)
if payload is None:
abort(404)
view = build_series_view(series_id, payload)
return render_template("series.html", series=view)
@app.route("/bracket")
def bracket():
year = datetime.now(_EASTERN).year
payload, fetched_at = get_bracket(year)
if payload is None:
payload = refresh_bracket(year)
if payload is None:
abort(404)
view = build_bracket_view(year, payload, fetched_at=fetched_at)
return render_template("bracket.html", bracket=view)
+4
View File
@@ -4,6 +4,7 @@ import time
import schedule
from app.api import refresh_scores
from app.playoff_cache import refresh_bracket
from app.standings import refresh_standings
logger = logging.getLogger(__name__)
@@ -12,6 +13,9 @@ logger = logging.getLogger(__name__)
def start_scheduler():
schedule.every(600).seconds.do(refresh_standings)
schedule.every(10).seconds.do(refresh_scores)
schedule.every(3600).seconds.do(refresh_bracket)
# Populate the cache once at startup so the banner has data immediately.
refresh_bracket()
logger.info("Background scheduler started")
while True:
try:
+193
View File
@@ -0,0 +1,193 @@
"""Normalize NHL /v1/schedule/playoff-series payloads for the series template.
The API payload is verbose and nested; this module flattens it into a small
render-ready dict so series.html can stay simple.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from app.playoff import ROUND_LABELS, ot_label, series_state
EASTERN = ZoneInfo("America/New_York")
_STATE_LABELS = {
"FUT": "Scheduled",
"PRE": "Pregame",
"LIVE": "Live",
"CRIT": "Live",
"OFF": "Final",
"FINAL": "Final",
}
def build_series_view(series_id, payload):
"""Return a dict shaped for rendering in series.html.
`payload` is the raw JSON from /v1/schedule/playoff-series/{season}/{letter}.
"""
top = payload.get("topSeedTeam", {}) or {}
bot = payload.get("bottomSeedTeam", {}) or {}
games = payload.get("games", []) or []
top_wins = _to_int(top.get("seriesWins"))
bot_wins = _to_int(bot.get("seriesWins"))
needed = _to_int(payload.get("neededToWin"), default=4)
state = series_state(
{
"round": _to_int(payload.get("round"), default=1),
"topSeedWins": top_wins,
"bottomSeedWins": bot_wins,
"topSeedTeamAbbrev": top.get("abbrev"),
"bottomSeedTeamAbbrev": bot.get("abbrev"),
}
)
leader_team = None
trailer_team = None
if state["leader"] == "top":
leader_team, trailer_team = _team_view(top), _team_view(bot)
elif state["leader"] == "bottom":
leader_team, trailer_team = _team_view(bot), _team_view(top)
normalized_games = [_game_view(g) for g in games]
played = [g for g in normalized_games if g["state_group"] == "completed"]
upcoming = [g for g in normalized_games if g["state_group"] != "completed"]
next_game = upcoming[0] if upcoming else None
round_num = _to_int(payload.get("round"), default=1)
return {
"series_id": series_id,
"round": round_num,
"round_label": payload.get("roundLabel")
or ROUND_LABELS.get(round_num, f"Round {round_num}"),
"series_letter": payload.get("seriesLetter"),
"needed_to_win": needed,
"length": _to_int(payload.get("length"), default=7),
"top": _team_view(top),
"bottom": _team_view(bot),
"top_wins": top_wins,
"bottom_wins": bot_wins,
"leader": leader_team,
"trailer": trailer_team,
"state": state,
"headline": _headline(state, leader_team, trailer_team, top_wins, bot_wins),
"games": normalized_games,
"played_games": played,
"next_game": next_game,
"series_logo": payload.get("seriesLogo"),
}
def _team_view(team):
if not team:
return None
name = (team.get("name") or {}).get("default") or team.get("abbrev", "")
place = (team.get("placeName") or {}).get("default") or ""
return {
"id": team.get("id"),
"name": name,
"place": place,
"full": f"{place} {name}".strip() if place else name,
"abbrev": team.get("abbrev"),
"logo": team.get("darkLogo") or team.get("logo"),
"record": team.get("record"),
"seed": team.get("seed"),
"series_wins": _to_int(team.get("seriesWins")),
"division": team.get("divisionAbbrev"),
"conference": (team.get("conference") or {}).get("abbrev"),
}
def _game_view(game):
gs = game.get("gameState", "")
state_label = _STATE_LABELS.get(gs, gs.title() if gs else "Scheduled")
completed = gs in ("OFF", "FINAL")
live = gs in ("LIVE", "CRIT")
home = game.get("homeTeam", {}) or {}
away = game.get("awayTeam", {}) or {}
start_local, start_date = _format_start(game.get("startTimeUTC"))
last_period = (game.get("gameOutcome") or {}).get("lastPeriodType") or ""
period_num = _to_int((game.get("periodDescriptor") or {}).get("number"))
ended_in_ot = completed and last_period == "OT"
ended_multi_ot = completed and period_num >= 4 and last_period == "OT"
winner_abbrev = None
if completed:
home_score = _to_int(home.get("score"))
away_score = _to_int(away.get("score"))
if home_score > away_score:
winner_abbrev = home.get("abbrev")
elif away_score > home_score:
winner_abbrev = away.get("abbrev")
return {
"id": game.get("id"),
"game_number": _to_int(game.get("gameNumber"), default=1),
"if_necessary": bool(game.get("ifNecessary")),
"venue": (game.get("venue") or {}).get("default", ""),
"start_utc": game.get("startTimeUTC"),
"start_local": start_local,
"start_date": start_date,
"state": gs,
"state_label": state_label,
"state_group": "completed" if completed else ("live" if live else "upcoming"),
"live": live,
"period_number": period_num,
"period_ot_label": ot_label(period_num) if live and period_num >= 4 else "",
"ended_in_ot": ended_in_ot,
"ended_in_multi_ot": ended_multi_ot,
"home": {
"abbrev": home.get("abbrev"),
"name": (home.get("commonName") or {}).get("default"),
"place": (home.get("placeName") or {}).get("default"),
"score": _to_int(home.get("score")) if completed or live else None,
},
"away": {
"abbrev": away.get("abbrev"),
"name": (away.get("commonName") or {}).get("default"),
"place": (away.get("placeName") or {}).get("default"),
"score": _to_int(away.get("score")) if completed or live else None,
},
"winner_abbrev": winner_abbrev,
}
def _headline(state, leader, trailer, top_wins, bot_wins):
if state["is_game7"]:
return "Win-or-go-home \u2014 Game 7 tonight."
if state["is_clincher"] and leader:
return f"{leader['full']} can close it out in Game {state['game_number']}."
if state["is_pivotal"]:
return f"Series tied 2\u20112 \u2014 pivotal Game {state['game_number']}."
if state["is_opener"]:
return "Series opener."
if leader and trailer:
return (
f"{leader['full']} leads {state['hi']}\u2011{state['lo']} "
f"\u2014 Game {state['game_number']} next."
)
return f"Series even {top_wins}\u2011{bot_wins}."
def _format_start(start_utc):
if not start_utc:
return "", ""
try:
dt = datetime.fromisoformat(start_utc.replace("Z", "+00:00")).astimezone(
EASTERN
)
except ValueError:
return "", ""
return dt.strftime("%-I:%M %p ET"), dt.strftime("%a %b %-d")
def _to_int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
+216 -21
View File
@@ -9,11 +9,14 @@ async function fetchScoreboardData() {
}
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) {
@@ -32,23 +35,96 @@ function updateScoreboard(data) {
}
updateGauges();
maybeNotifyOT(data);
}
// ── Banner / Meta ─────────────────────────────────────
function applyMeta(meta) {
const banner = document.getElementById('playoff-banner');
if (!meta || !meta.playoff_mode) {
document.body.classList.remove('playoff-mode');
banner.classList.add('hidden');
banner.setAttribute('aria-hidden', 'true');
return;
}
document.body.classList.add('playoff-mode');
banner.classList.remove('hidden');
banner.setAttribute('aria-hidden', 'false');
setText(banner.querySelector('.banner-year'), meta.year ? String(meta.year) : '');
setText(banner.querySelector('.meta-round'), meta.round_label || '');
const dayEl = banner.querySelector('.meta-day');
if (meta.day_n != null) {
const total = meta.day_total ? ` of ~${meta.day_total}` : '';
setText(dayEl, `Day ${meta.day_n}${total}`);
dayEl.classList.remove('hidden');
} else {
dayEl.classList.add('hidden');
}
const seriesEl = banner.querySelector('.meta-series');
if (meta.series_active) {
const word = meta.series_active === 1 ? 'series' : 'series';
setText(seriesEl, `${meta.series_active} ${word} in action`);
seriesEl.classList.remove('hidden');
} else {
seriesEl.classList.add('hidden');
}
const elimEl = banner.querySelector('.meta-elim');
if (meta.elimination_count > 0) {
const n = meta.elimination_count;
setText(elimEl, `${n} elimination game${n === 1 ? '' : 's'}`);
elimEl.classList.remove('hidden');
} else {
elimEl.classList.add('hidden');
}
const g7El = banner.querySelector('.meta-game7');
if (meta.game7_count > 0) {
const n = meta.game7_count;
setText(g7El, `${n} Game 7${n === 1 ? '' : 's'}`);
g7El.classList.remove('hidden');
} else {
g7El.classList.add('hidden');
}
}
function setText(el, text) {
if (el) el.textContent = text;
}
// ── Renderers ────────────────────────────────────────
function renderLiveGame(game) {
function renderPinnedGame(game) {
if (game['Game State'] === 'PRE') return renderPreGame(game, { pinned: true });
if (game['Game State'] === 'FINAL') return renderFinalGame(game, { pinned: true });
return renderLiveGame(game, { pinned: true });
}
function renderLiveGame(game, opts = {}) {
const intermission = game['Intermission'];
const period = game['Period'];
const time = game['Time Remaining'];
const running = game['Time Running'];
const isPlayoff = game['Is Playoff'];
const playoffOT = game['Playoff OT'];
const periodLabel = intermission
const periodText = playoffOT
? (game['OT Label'] || 'OT')
: ordinalPeriod(period);
const periodBadge = intermission
? `<span class="badge">${intermissionLabel(period)}</span>`
: `<span class="badge badge-live">${ordinalPeriod(period)}</span>`;
: playoffOT
? `<span class="badge badge-sudden-death">${periodText} &middot; SUDDEN DEATH</span>`
: `<span class="badge badge-live">${periodText}</span>`;
const dot = running ? `<span class="live-dot"></span>` : '';
// Tick the clock locally when the clock is running or during intermission
const shouldTick = running || intermission;
const rawSeconds = timeToSeconds(time);
const clockAttrs = shouldTick
@@ -63,11 +139,17 @@ function renderLiveGame(game) {
</div>
</div>` : '';
return `
<div class="game-box ${intermission ? 'game-box-intermission' : 'game-box-live'}" data-game-key="${game['Away Team']}|${game['Home Team']}">
const stateClass = intermission ? 'game-box-intermission' : 'game-box-live';
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
const otClass = playoffOT ? ' game-box-sudden-death' : '';
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
return wrapSeriesLink(game, `
<div class="game-box ${stateClass}${playoffClass}${otClass}${pinnedClass}" data-game-key="${gameKey(game)}">
${playoffContext(game)}
<div class="card-header">
<div class="badges">
${periodLabel}
${periodBadge}
<span class="badge" ${clockAttrs}>${time}</span>
${ppBadge(game)}
</div>
@@ -76,12 +158,17 @@ function renderLiveGame(game) {
${teamRow(game, 'Away', 'live')}
${teamRow(game, 'Home', 'live')}
${hype}
</div>`;
${seriesBlurb(game)}
</div>`);
}
function renderPreGame(game) {
return `
<div class="game-box">
function renderPreGame(game, opts = {}) {
const isPlayoff = game['Is Playoff'];
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
return wrapSeriesLink(game, `
<div class="game-box${playoffClass}${pinnedClass}" data-game-key="${gameKey(game)}">
${playoffContext(game)}
<div class="card-header">
<div class="badges">
<span class="badge">${game['Start Time']}</span>
@@ -89,14 +176,19 @@ function renderPreGame(game) {
</div>
${teamRow(game, 'Away', 'pre')}
${teamRow(game, 'Home', 'pre')}
</div>`;
${seriesBlurb(game)}
</div>`);
}
function renderFinalGame(game) {
function renderFinalGame(game, opts = {}) {
const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' };
const label = labels[game['Last Period Type']] ?? 'Final';
return `
<div class="game-box">
const isPlayoff = game['Is Playoff'];
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
return wrapSeriesLink(game, `
<div class="game-box${playoffClass}${pinnedClass}" data-game-key="${gameKey(game)}">
${playoffContext(game)}
<div class="card-header">
<div class="badges">
<span class="badge badge-muted">${label}</span>
@@ -104,7 +196,42 @@ function renderFinalGame(game) {
</div>
${teamRow(game, 'Away', 'final')}
${teamRow(game, 'Home', 'final')}
</div>`;
${seriesBlurb(game)}
</div>`);
}
function wrapSeriesLink(game, html) {
const sid = game['Series ID'];
if (!sid) return html;
return `<a class="series-link" href="/series/${sid}" aria-label="Series detail">${html}</a>`;
}
// ── Playoff context (badges row + series summary) ─────
function playoffContext(game) {
if (!game['Is Playoff']) return '';
const badges = (game['Series Badges'] || [])
.map(b => `<span class="badge ${badgeClassFor(b)}">${b}</span>`)
.join('');
const summary = game['Series Summary']
? `<span class="series-summary">${game['Series Summary']}</span>`
: '';
if (!badges && !summary) return '';
return `<div class="playoff-context">${badges}${summary}</div>`;
}
function badgeClassFor(label) {
if (label === 'GAME 7') return 'badge-game7';
if (label === 'CLINCHER') return 'badge-clincher';
if (label === 'PIVOTAL') return 'badge-pivotal';
if (label === 'CUP FINAL') return 'badge-round badge-cup';
if (label === 'CONF FINAL')return 'badge-round badge-conf';
return 'badge-round';
}
function seriesBlurb(game) {
if (!game['Is Playoff'] || !game['Series Blurb']) return '';
return `<div class="series-blurb">${game['Series Blurb']}</div>`;
}
// ── Team Row ─────────────────────────────────────────
@@ -148,6 +275,10 @@ function ppBadge(game) {
return `<span class="badge badge-pp">PP ${team} <span ${attrs}>${timeStr}</span></span>`;
}
function gameKey(game) {
return `${game['Away Team']}|${game['Home Team']}`;
}
// ── Gauge ────────────────────────────────────────────
function updateGauges() {
@@ -197,7 +328,6 @@ function restoreClocks(grid, snapshot) {
if (!prior) return;
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
if (!badge) return;
// Only restore if we're outside the final sync window
if (prior.current > CLOCK_SYNC_THRESHOLD) {
badge.dataset.seconds = prior.current;
badge.dataset.receivedAt = prior.ts;
@@ -226,6 +356,70 @@ function intermissionLabel(period) {
return ['1st Int', '2nd Int', '3rd Int'][period - 1] ?? 'Int';
}
// ── OT Notifications (Phase 1: client-only) ──────────
const OT_SEEN_KEY = 'nhl_ot_seen_v1';
function seenOTKeys() {
try { return new Set(JSON.parse(sessionStorage.getItem(OT_SEEN_KEY) || '[]')); }
catch { return new Set(); }
}
function persistSeenOT(set) {
sessionStorage.setItem(OT_SEEN_KEY, JSON.stringify([...set]));
}
function maybeNotifyOT(data) {
if (!('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
const seen = seenOTKeys();
const candidates = [...(data.pinned_games || []), ...(data.live_games || [])];
let changed = false;
for (const g of candidates) {
if (!g['Playoff OT']) continue;
const k = `${gameKey(g)}|${g['Period']}`;
if (seen.has(k)) continue;
seen.add(k);
changed = true;
try {
new Notification('Playoff OT \u2014 Sudden Death', {
body: `${g['Away Team']} @ ${g['Home Team']}`,
silent: false,
tag: k,
});
} catch (e) {
console.warn('Notification failed:', e);
}
}
if (changed) persistSeenOT(seen);
}
function wireOTButton() {
const btn = document.querySelector('.banner-notify');
if (!btn) return;
if (!('Notification' in window)) {
btn.disabled = true;
btn.title = 'Notifications not supported in this browser';
return;
}
reflectOTPermission(btn);
btn.addEventListener('click', async () => {
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
reflectOTPermission(btn);
});
}
function reflectOTPermission(btn) {
const state = Notification.permission;
btn.dataset.perm = state;
if (state === 'granted') btn.title = 'OT alerts enabled';
else if (state === 'denied') btn.title = 'OT alerts blocked in browser settings';
else btn.title = 'Enable OT alerts';
}
// ── Init ─────────────────────────────────────────────
function autoRefresh() {
@@ -234,6 +428,7 @@ function autoRefresh() {
}
window.addEventListener('load', () => {
wireOTButton();
autoRefresh();
setInterval(tickClocks, 1000);
if ('serviceWorker' in navigator) {
+790
View File
@@ -12,6 +12,19 @@
--gap: 1rem;
--radius: 12px;
--card-w: 290px;
/* Cup theme palette — only referenced when body.playoff-mode is set */
--cup-gold-1: #d4af37;
--cup-gold-2: #f5d76e;
--cup-gold-dim: #8a6d1a;
--cup-silver-1: #c0c8d0;
--cup-silver-2: #e8ecef;
--cup-silver-dim: #6b7580;
--ice-1: #0a1628;
--ice-2: #162844;
--ice-accent: #4fc3f7;
--gold-gradient: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-2) 50%, var(--cup-gold-1));
--banner-bg: linear-gradient(135deg, #0a1628 0%, #162844 55%, #1a1a2e 100%);
}
*, *::before, *::after {
@@ -352,3 +365,780 @@ main {
flex-direction: column;
}
}
/* ── Playoff Banner ─────────────────────────────── */
.playoff-banner {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: var(--banner-bg);
border-bottom: 2px solid transparent;
border-image: var(--gold-gradient) 1;
position: relative;
}
.playoff-banner.hidden {
display: none;
}
.banner-trophy {
width: 36px;
height: 44px;
flex-shrink: 0;
filter: drop-shadow(0 0 6px rgba(212, 175, 55, 0.25));
}
.banner-text {
flex: 1;
min-width: 0;
}
.banner-title {
font-size: 0.95rem;
font-weight: 800;
letter-spacing: 0.12em;
background: var(--gold-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
line-height: 1.2;
}
.banner-year {
color: var(--cup-silver-2);
-webkit-text-fill-color: var(--cup-silver-2);
font-weight: 700;
margin-left: 0.35rem;
}
.banner-meta {
margin-top: 0.2rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.7rem;
font-size: 0.75rem;
color: var(--cup-silver-1);
letter-spacing: 0.03em;
}
.banner-meta > span.hidden { display: none; }
.banner-meta > span:not(.hidden) + span:not(.hidden)::before {
content: "\00b7";
color: var(--cup-gold-dim);
margin-right: 0.7rem;
}
.meta-elim, .meta-game7 {
color: var(--cup-gold-2);
font-weight: 700;
}
.banner-notify {
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--cup-gold-dim);
color: var(--cup-gold-2);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.45rem 0.7rem;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background 120ms ease, border-color 120ms ease;
}
.banner-notify:hover {
background: rgba(212, 175, 55, 0.18);
border-color: var(--cup-gold-1);
}
.banner-notify[data-perm="granted"] {
background: rgba(212, 175, 55, 0.2);
border-color: var(--cup-gold-1);
color: var(--cup-gold-2);
}
.banner-notify[data-perm="denied"] {
opacity: 0.5;
cursor: not-allowed;
}
@media (min-width: 900px) {
.playoff-banner {
padding: 1.125rem 2rem;
gap: 1.25rem;
}
.banner-trophy { width: 44px; height: 54px; }
.banner-title { font-size: 1.15rem; }
.banner-meta { font-size: 0.85rem; }
}
@media (max-width: 640px) {
.playoff-banner {
flex-wrap: wrap;
padding: 0.75rem 1rem;
}
.banner-notify {
order: 3;
margin-left: auto;
}
}
/* ── Playoff Game Cards ─────────────────────────── */
.playoff-mode .section-heading-gold {
background: var(--gold-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
letter-spacing: 0.18em;
font-weight: 800;
}
.game-box-playoff {
border-top-width: 3px;
border-image: var(--gold-gradient) 1;
border-image-slice: 1;
}
/* Gold top stripe wins over the green-accent live stripe on playoff games */
.game-box-playoff.game-box-live,
.game-box-playoff.game-box-intermission {
border-image: var(--gold-gradient) 1;
}
.game-box-pinned {
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.35),
0 6px 20px rgba(212, 175, 55, 0.1);
}
.game-box-sudden-death {
border-top-width: 3px;
animation: pulse-gold 2.2s ease-in-out infinite;
}
@keyframes pulse-gold {
0%, 100% {
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.35),
0 0 10px rgba(212, 175, 55, 0.25);
}
50% {
box-shadow: 0 0 0 1px rgba(245, 215, 110, 0.75),
0 0 22px rgba(245, 215, 110, 0.45);
}
}
/* Playoff context row above the period badges */
.playoff-context {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.6rem;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--card-border);
}
.series-summary {
font-size: 0.72rem;
color: var(--cup-silver-1);
font-weight: 600;
letter-spacing: 0.02em;
}
/* Playoff stake badges */
.badge-round {
background: rgba(212, 175, 55, 0.1);
color: var(--cup-gold-2);
border: 1px solid rgba(212, 175, 55, 0.35);
}
.badge-conf {
background: rgba(79, 195, 247, 0.12);
color: var(--ice-accent);
border-color: rgba(79, 195, 247, 0.4);
}
.badge-cup {
background: linear-gradient(90deg, rgba(212, 175, 55, 0.25), rgba(245, 215, 110, 0.25));
color: #1a1200;
border: 1px solid var(--cup-gold-1);
font-weight: 800;
}
.badge-game7 {
background: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-1));
color: #1a1200;
border: 1px solid var(--cup-gold-2);
font-weight: 800;
letter-spacing: 0.1em;
}
.badge-clincher {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.45);
}
.badge-pivotal {
background: rgba(79, 195, 247, 0.12);
color: var(--ice-accent);
border: 1px solid rgba(79, 195, 247, 0.4);
}
.badge-sudden-death {
background: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-1));
color: #1a1200;
border: 1px solid var(--cup-gold-2);
font-weight: 800;
letter-spacing: 0.08em;
}
.series-blurb {
margin-top: 0.65rem;
padding-top: 0.55rem;
border-top: 1px solid var(--card-border);
font-size: 0.72rem;
color: var(--cup-silver-1);
letter-spacing: 0.01em;
font-style: italic;
text-align: center;
}
/* In playoff mode, retint the hype meter to silver → gold */
.playoff-mode .hype-label {
color: var(--cup-silver-dim);
}
/* Override the red live dot with gold for playoff-mode bodies */
.playoff-mode .game-box-playoff .live-dot {
background: var(--cup-gold-1);
box-shadow: 0 0 6px rgba(245, 215, 110, 0.7);
}
@media (min-width: 900px) {
.series-summary { font-size: 0.82rem; }
.series-blurb { font-size: 0.82rem; }
}
@media (min-width: 1400px) {
.series-summary { font-size: 0.9rem; }
.series-blurb { font-size: 0.9rem; }
}
.series-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem 0.25rem;
}
.series-header .header-title {
font-size: 1.3rem;
}
/* Clickable playoff card wrapper */
.series-link {
display: contents;
color: inherit;
text-decoration: none;
}
.series-link .game-box {
cursor: pointer;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.series-link:hover .game-box {
border-color: var(--cup-gold-1);
transform: translateY(-1px);
}
.series-link:focus-visible .game-box {
outline: 2px solid var(--cup-gold-2);
outline-offset: 2px;
}
/* ── Series detail page (/series/<id>) ──────────── */
.header-link {
text-decoration: none;
color: var(--cup-silver-2);
}
.series-main {
padding: 1rem 1.25rem 3rem;
max-width: 1000px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.series-hero {
background: var(--banner-bg);
border: 1px solid var(--cup-gold-dim);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
text-align: center;
}
.series-hero-eyebrow {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: center;
}
.series-teams {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
width: 100%;
}
.series-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 0.5rem;
border-radius: 10px;
border: 1px solid transparent;
}
.series-team-leader {
border-color: var(--cup-gold-dim);
background: rgba(212, 175, 55, 0.06);
}
.series-team-logo {
width: 72px;
height: 72px;
object-fit: contain;
}
.series-team-name {
font-weight: 600;
font-size: 1.05rem;
color: var(--cup-silver-2);
}
.series-team-meta {
font-size: 0.78rem;
color: var(--cup-silver-dim);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.series-team-wins {
font-size: 2.5rem;
font-weight: 700;
color: var(--cup-gold-2);
line-height: 1;
margin-top: 0.25rem;
}
.series-versus {
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: center;
}
.series-versus-label {
font-size: 0.7rem;
letter-spacing: 0.15em;
color: var(--cup-silver-dim);
}
.series-versus-score {
font-size: 1.4rem;
font-weight: 600;
color: var(--cup-gold-2);
}
.series-versus-best {
font-size: 0.75rem;
color: var(--cup-silver-dim);
}
.series-headline {
font-size: 1rem;
color: var(--cup-silver-1);
max-width: 46ch;
}
.series-next-card {
background: var(--card);
border: 1px solid var(--cup-gold-dim);
border-radius: 10px;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: center;
margin-top: 0.6rem;
}
.series-next-matchup {
display: flex;
gap: 0.6rem;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
}
.series-next-team {
color: var(--cup-silver-2);
}
.series-next-at {
color: var(--cup-silver-dim);
font-size: 0.9rem;
}
.series-next-meta {
font-size: 0.85rem;
color: var(--cup-silver-1);
text-align: center;
}
.series-games {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.6rem;
}
.series-game {
display: grid;
grid-template-columns: 90px 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 10px;
}
.series-game-live,
.series-game-completed {
border-color: var(--cup-gold-dim);
}
.series-game-col-number {
font-weight: 600;
color: var(--cup-silver-1);
letter-spacing: 0.03em;
font-size: 0.85rem;
}
.series-game-col-matchup {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.series-game-team {
display: flex;
justify-content: space-between;
gap: 0.6rem;
font-size: 0.95rem;
}
.series-game-abbrev {
font-weight: 600;
color: var(--cup-silver-2);
}
.series-game-score {
color: var(--cup-silver-1);
font-variant-numeric: tabular-nums;
min-width: 1.6em;
text-align: right;
}
.series-game-winner {
color: var(--cup-gold-2);
font-weight: 700;
}
.series-game-state {
font-size: 0.8rem;
color: var(--cup-silver-dim);
text-align: right;
}
@media (max-width: 600px) {
.series-teams {
grid-template-columns: 1fr;
}
.series-versus {
order: 3;
}
.series-team-logo {
width: 56px;
height: 56px;
}
.series-game {
grid-template-columns: 70px 1fr;
}
.series-game-col-state {
grid-column: 1 / -1;
text-align: left;
}
.series-game-state {
text-align: left;
}
}
/* ── Bracket page (/bracket) ─────────────────────── */
.bracket-main {
padding: 1rem 1.25rem 3rem;
max-width: 1600px;
margin: 0 auto;
}
.bracket-hero {
text-align: center;
margin-bottom: 1.5rem;
}
.bracket-title {
font-size: 1.6rem;
font-weight: 700;
color: var(--cup-silver-2);
letter-spacing: 0.05em;
background: var(--gold-gradient);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.bracket-subtitle {
color: var(--cup-silver-dim);
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* Desktop grid layout */
.bracket-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.75rem;
align-items: stretch;
}
.bracket-col {
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 0.6rem;
}
.bracket-col-heading {
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cup-silver-dim);
text-align: center;
margin-bottom: 0.25rem;
}
.bracket-cup-heading {
color: var(--cup-gold-2);
}
.bracket-col-cup {
justify-content: center;
}
.bracket-matchup {
display: flex;
flex-direction: column;
gap: 2px;
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 0.45rem 0.55rem;
text-decoration: none;
color: inherit;
transition: border-color 0.12s ease, transform 0.12s ease;
}
.bracket-matchup:hover {
border-color: var(--cup-gold-1);
transform: translateY(-1px);
}
.bracket-matchup:focus-visible {
outline: 2px solid var(--cup-gold-2);
outline-offset: 2px;
}
.bracket-matchup-active {
border-color: var(--cup-gold-dim);
}
.bracket-matchup-complete {
opacity: 0.75;
}
.bracket-matchup-empty {
background: transparent;
border-style: dashed;
}
.bracket-col-cup .bracket-matchup {
border-color: var(--cup-gold-dim);
background: linear-gradient(135deg, #162844 0%, #1a1a2e 100%);
padding: 0.7rem 0.6rem;
}
.bracket-col-cup .bracket-matchup:hover {
border-color: var(--cup-gold-2);
}
.bracket-team {
display: grid;
grid-template-columns: 22px 1fr auto auto;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
font-size: 0.85rem;
}
.bracket-team-logo {
width: 22px;
height: 22px;
object-fit: contain;
}
.bracket-team-abbrev {
font-weight: 600;
color: var(--cup-silver-2);
}
.bracket-team-seed {
font-size: 0.7rem;
color: var(--cup-silver-dim);
letter-spacing: 0.03em;
}
.bracket-team-wins {
font-variant-numeric: tabular-nums;
min-width: 1.2em;
text-align: right;
color: var(--cup-silver-1);
font-weight: 600;
}
.bracket-team-winner {
color: var(--cup-gold-2);
}
.bracket-team-winner .bracket-team-abbrev,
.bracket-team-winner .bracket-team-wins {
color: var(--cup-gold-2);
}
.bracket-team-placeholder {
color: var(--cup-silver-dim);
text-align: center;
padding: 0.3rem 0;
font-size: 0.8rem;
grid-template-columns: 1fr;
}
/* Mobile accordion — hidden on desktop */
.bracket-accordion {
display: none;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.bracket-round {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 10px;
overflow: hidden;
}
.bracket-round-summary {
padding: 0.8rem 1rem;
cursor: pointer;
font-weight: 600;
color: var(--cup-gold-2);
letter-spacing: 0.03em;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.bracket-round-summary::after {
content: "+";
color: var(--cup-silver-dim);
font-size: 1.2rem;
}
.bracket-round[open] .bracket-round-summary::after {
content: "";
}
.bracket-round-body {
padding: 0 0.8rem 0.8rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.bracket-round-half-heading {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cup-silver-dim);
margin-bottom: 0.3rem;
}
.bracket-round-half {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
/* Switch layout at narrow widths */
@media (max-width: 900px) {
.bracket-grid { display: none; }
.bracket-accordion { display: flex; }
}
/* Banner bracket link (both pages) */
.banner-bracket-link {
background: transparent;
border: 1px solid var(--cup-gold-dim);
color: var(--cup-gold-2);
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.78rem;
letter-spacing: 0.05em;
text-decoration: none;
transition: border-color 0.12s ease, color 0.12s ease;
white-space: nowrap;
}
.banner-bracket-link:hover {
border-color: var(--cup-gold-1);
color: var(--cup-gold-2);
}
+15 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'nhl-scoreboard-v1';
const CACHE = 'nhl-scoreboard-v3';
const PRECACHE = [
'/',
'/static/styles.css',
@@ -33,6 +33,20 @@ self.addEventListener('fetch', event => {
return;
}
// Network-first for bracket + series detail pages; fall back to cache offline
if (pathname === '/bracket' || pathname.startsWith('/series/')) {
event.respondWith(
fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE).then(c => c.put(event.request, clone));
}
return response;
}).catch(() => caches.match(event.request))
);
return;
}
// Cache-first for everything else (static assets, shell)
event.respondWith(
caches.match(event.request).then(cached => {
+21
View File
@@ -0,0 +1,21 @@
{% if m.empty %}
<div class="bracket-matchup bracket-matchup-empty">
<div class="bracket-team bracket-team-placeholder">TBD</div>
<div class="bracket-team bracket-team-placeholder">TBD</div>
</div>
{% else %}
<a class="bracket-matchup bracket-matchup-{{ m.state }}" href="/series/{{ m.series_id }}">
<div class="bracket-team {% if m.winner_abbrev == m.top.abbrev %}bracket-team-winner{% endif %}">
{% if m.top.logo %}<img class="bracket-team-logo" src="{{ m.top.logo }}" alt="{{ m.top.abbrev }}">{% endif %}
<span class="bracket-team-abbrev">{{ m.top.abbrev }}</span>
{% if m.top.seed %}<span class="bracket-team-seed">{{ m.top.seed }}</span>{% endif %}
<span class="bracket-team-wins">{{ m.top_wins }}</span>
</div>
<div class="bracket-team {% if m.winner_abbrev == m.bottom.abbrev %}bracket-team-winner{% endif %}">
{% if m.bottom.logo %}<img class="bracket-team-logo" src="{{ m.bottom.logo }}" alt="{{ m.bottom.abbrev }}">{% endif %}
<span class="bracket-team-abbrev">{{ m.bottom.abbrev }}</span>
{% if m.bottom.seed %}<span class="bracket-team-seed">{{ m.bottom.seed }}</span>{% endif %}
<span class="bracket-team-wins">{{ m.bottom_wins }}</span>
</div>
</a>
{% endif %}
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ bracket.year }} Stanley Cup Bracket</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png">
<link rel="stylesheet" type="text/css" href="/static/styles.css">
</head>
<body class="playoff-mode bracket-mode">
<header>
<a class="header-title header-link" href="/">&larr; NHL Scoreboard</a>
</header>
<main class="bracket-main">
<section class="bracket-hero">
<h1 class="bracket-title">{{ bracket.year }} Stanley Cup Playoffs</h1>
<div class="bracket-subtitle">The road to 16 wins</div>
</section>
{# Desktop: 7-column grid (East R1 | R2 | CF | Cup | CF | R2 | R1 West) #}
<section class="bracket-grid" aria-label="Full playoff bracket">
<div class="bracket-col bracket-col-r1 bracket-col-east">
<h2 class="bracket-col-heading">First Round</h2>
{% for m in bracket.east_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-r2 bracket-col-east">
<h2 class="bracket-col-heading">Second Round</h2>
{% for m in bracket.east_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-cf bracket-col-east">
<h2 class="bracket-col-heading">East Final</h2>
{% for m in bracket.east_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-cup">
<h2 class="bracket-col-heading bracket-cup-heading">Cup Final</h2>
{% for m in bracket.cup %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-cf bracket-col-west">
<h2 class="bracket-col-heading">West Final</h2>
{% for m in bracket.west_cf %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-r2 bracket-col-west">
<h2 class="bracket-col-heading">Second Round</h2>
{% for m in bracket.west_r2 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
<div class="bracket-col bracket-col-r1 bracket-col-west">
<h2 class="bracket-col-heading">First Round</h2>
{% for m in bracket.west_r1 %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
</section>
{# Mobile: round-by-round accordion, round 1 open by default #}
<section class="bracket-accordion" aria-label="Playoff bracket by round">
{% for rnd in bracket.rounds %}
<details class="bracket-round" {% if loop.first %}open{% endif %}>
<summary class="bracket-round-summary">{{ rnd.label }}</summary>
<div class="bracket-round-body">
{% if rnd.get('east') %}
<div class="bracket-round-half">
<h3 class="bracket-round-half-heading">Eastern</h3>
{% for m in rnd.east %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
{% endif %}
{% if rnd.get('west') %}
<div class="bracket-round-half">
<h3 class="bracket-round-half-heading">Western</h3>
{% for m in rnd.west %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
{% endif %}
{% if rnd.get('cup') %}
<div class="bracket-round-half">
{% for m in rnd.cup %}{% include "_bracket_matchup.html" %}{% endfor %}
</div>
{% endif %}
</div>
</details>
{% endfor %}
</section>
</main>
</body>
</html>
+35
View File
@@ -16,7 +16,42 @@
<header>
<span class="header-title">NHL Scoreboard</span>
</header>
<section id="playoff-banner" class="playoff-banner hidden" aria-hidden="true">
<svg class="banner-trophy" viewBox="0 0 32 40" aria-hidden="true">
<defs>
<linearGradient id="cup-gold" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#f5d76e"/>
<stop offset="60%" stop-color="#d4af37"/>
<stop offset="100%" stop-color="#8a6d1a"/>
</linearGradient>
</defs>
<path fill="url(#cup-gold)" d="M6 2h20v4c0 5-2 9-5 11l-1 5h-8l-1-5C8 15 6 11 6 6V2zm4 20h12v3H10v-3zm-2 4h16v3H8v-3zm1 4h14v6H9v-6z"/>
<rect x="11" y="9" width="10" height="2" fill="#0a1628" opacity="0.35"/>
<rect x="11" y="13" width="10" height="1.5" fill="#0a1628" opacity="0.35"/>
</svg>
<div class="banner-text">
<div class="banner-title">
STANLEY CUP PLAYOFFS
<span class="banner-year"></span>
</div>
<div class="banner-meta">
<span class="meta-round"></span>
<span class="meta-day hidden"></span>
<span class="meta-series"></span>
<span class="meta-elim hidden"></span>
<span class="meta-game7 hidden"></span>
</div>
</div>
<a class="banner-bracket-link" href="/bracket">Bracket</a>
<button class="banner-notify" type="button" title="Notify me on playoff OT" aria-label="Enable OT notifications">
<span class="bell-label">OT alerts</span>
</button>
</section>
<main>
<section id="pinned-section" class="section pinned-section hidden">
<h2 class="section-heading section-heading-gold">Spotlight &middot; Game 7</h2>
<div id="pinned-games-section" class="games-grid"></div>
</section>
<section id="live-section" class="section hidden">
<h2 class="section-heading">Live</h2>
<div id="live-games-section" class="games-grid"></div>
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ series.top.abbrev }} vs {{ series.bottom.abbrev }} &middot; {{ series.round_label }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0f172a">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/static/icon-32x32.png">
<link rel="stylesheet" type="text/css" href="/static/styles.css">
</head>
<body class="playoff-mode series-mode">
<header class="series-header">
<a class="header-title header-link" href="/">&larr; NHL Scoreboard</a>
<a class="banner-bracket-link" href="/bracket">Bracket</a>
</header>
<main class="series-main">
<section class="series-hero">
<div class="series-hero-eyebrow">
<span class="badge badge-round">{{ series.round_label|upper }}</span>
{% if series.state.is_game7 %}<span class="badge badge-game7">GAME 7</span>
{% elif series.state.is_clincher %}<span class="badge badge-clincher">CLINCHER</span>
{% elif series.state.is_pivotal %}<span class="badge badge-pivotal">PIVOTAL</span>{% endif %}
</div>
<div class="series-teams">
<div class="series-team {% if series.leader and series.leader.abbrev == series.top.abbrev %}series-team-leader{% endif %}">
{% if series.top.logo %}<img class="series-team-logo" src="{{ series.top.logo }}" alt="{{ series.top.abbrev }}">{% endif %}
<div class="series-team-name">{{ series.top.full }}</div>
<div class="series-team-meta">
{% if series.top.seed %}Seed {{ series.top.seed }}{% endif %}
{% if series.top.division %} &middot; {{ series.top.division }}{% endif %}
</div>
<div class="series-team-wins">{{ series.top_wins }}</div>
</div>
<div class="series-versus">
<span class="series-versus-label">SERIES</span>
<span class="series-versus-score">{{ series.top_wins }} &ndash; {{ series.bottom_wins }}</span>
<span class="series-versus-best">Best of {{ series.length }}</span>
</div>
<div class="series-team {% if series.leader and series.leader.abbrev == series.bottom.abbrev %}series-team-leader{% endif %}">
{% if series.bottom.logo %}<img class="series-team-logo" src="{{ series.bottom.logo }}" alt="{{ series.bottom.abbrev }}">{% endif %}
<div class="series-team-name">{{ series.bottom.full }}</div>
<div class="series-team-meta">
{% if series.bottom.seed %}Seed {{ series.bottom.seed }}{% endif %}
{% if series.bottom.division %} &middot; {{ series.bottom.division }}{% endif %}
</div>
<div class="series-team-wins">{{ series.bottom_wins }}</div>
</div>
</div>
<p class="series-headline">{{ series.headline }}</p>
</section>
{% if series.next_game %}
<section class="series-next">
<h2 class="section-heading section-heading-gold">Next up &middot; Game {{ series.next_game.game_number }}</h2>
<div class="series-next-card">
<div class="series-next-matchup">
<span class="series-next-team">{{ series.next_game.away.abbrev }}</span>
<span class="series-next-at">@</span>
<span class="series-next-team">{{ series.next_game.home.abbrev }}</span>
</div>
<div class="series-next-meta">
{% if series.next_game.start_date %}{{ series.next_game.start_date }}{% endif %}
{% if series.next_game.start_local %} &middot; {{ series.next_game.start_local }}{% endif %}
{% if series.next_game.venue %} &middot; {{ series.next_game.venue }}{% endif %}
{% if series.next_game.if_necessary %} &middot; <em>if necessary</em>{% endif %}
</div>
</div>
</section>
{% endif %}
<section class="series-history">
<h2 class="section-heading">Games</h2>
<ol class="series-games">
{% for game in series.games %}
<li class="series-game series-game-{{ game.state_group }}">
<div class="series-game-col-number">Game {{ game.game_number }}{% if game.if_necessary and game.state_group != 'completed' %}*{% endif %}</div>
<div class="series-game-col-matchup">
<div class="series-game-team">
<span class="series-game-abbrev">{{ game.away.abbrev }}</span>
<span class="series-game-score {% if game.winner_abbrev == game.away.abbrev %}series-game-winner{% endif %}">
{% if game.away.score is not none %}{{ game.away.score }}{% else %}&mdash;{% endif %}
</span>
</div>
<div class="series-game-team">
<span class="series-game-abbrev">{{ game.home.abbrev }}</span>
<span class="series-game-score {% if game.winner_abbrev == game.home.abbrev %}series-game-winner{% endif %}">
{% if game.home.score is not none %}{{ game.home.score }}{% else %}&mdash;{% endif %}
</span>
</div>
</div>
<div class="series-game-col-state">
{% if game.live %}
<span class="badge badge-live">LIVE</span>
{% if game.period_ot_label %}<span class="badge badge-sudden-death">{{ game.period_ot_label }}</span>{% endif %}
{% elif game.state_group == 'completed' %}
<span class="series-game-state">{{ game.state_label }}{% if game.ended_in_ot %} &middot; {{ 'OT' if not game.ended_in_multi_ot else 'Multi-OT' }}{% endif %}</span>
{% else %}
<span class="series-game-state">
{% if game.start_date %}{{ game.start_date }}{% endif %}
{% if game.start_local %} &middot; {{ game.start_local }}{% endif %}
</span>
{% endif %}
</div>
</li>
{% endfor %}
</ol>
</section>
</main>
</body>
</html>