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