From ebe770fecdf172deea287eff4c8469fbd9f44602 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 19 Apr 2026 12:47:31 -0400 Subject: [PATCH] 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/ 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 --- app/bracket_view.py | 132 +++++ app/games.py | 30 +- app/playoff.py | 272 ++++++++++ app/playoff_cache.py | 217 ++++++++ app/routes.py | 60 ++- app/scheduler.py | 4 + app/series_view.py | 193 +++++++ app/static/script.js | 237 ++++++++- app/static/styles.css | 790 ++++++++++++++++++++++++++++ app/static/sw.js | 16 +- app/templates/_bracket_matchup.html | 21 + app/templates/bracket.html | 82 +++ app/templates/index.html | 35 ++ app/templates/series.html | 110 ++++ tests/conftest.py | 49 ++ tests/test_bracket_view.py | 124 +++++ tests/test_games.py | 103 +++- tests/test_playoff_cache.py | 205 ++++++++ tests/test_playoff_series.py | 271 ++++++++++ tests/test_routes.py | 218 +++++++- tests/test_scheduler.py | 25 + 21 files changed, 3163 insertions(+), 31 deletions(-) create mode 100644 app/bracket_view.py create mode 100644 app/playoff.py create mode 100644 app/playoff_cache.py create mode 100644 app/series_view.py create mode 100644 app/templates/_bracket_matchup.html create mode 100644 app/templates/bracket.html create mode 100644 app/templates/series.html create mode 100644 tests/test_bracket_view.py create mode 100644 tests/test_playoff_cache.py create mode 100644 tests/test_playoff_series.py diff --git a/app/bracket_view.py b/app/bracket_view.py new file mode 100644 index 0000000..539b0f1 --- /dev/null +++ b/app/bracket_view.py @@ -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 diff --git a/app/games.py b/app/games.py index 58b5c59..b5e129a 100644 --- a/app/games.py +++ b/app/games.py @@ -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) diff --git a/app/playoff.py b/app/playoff.py new file mode 100644 index 0000000..805f683 --- /dev/null +++ b/app/playoff.py @@ -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" diff --git a/app/playoff_cache.py b/app/playoff_cache.py new file mode 100644 index 0000000..9c13e58 --- /dev/null +++ b/app/playoff_cache.py @@ -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 diff --git a/app/routes.py b/app/routes.py index 8ac643d..4f58763 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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/") +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) diff --git a/app/scheduler.py b/app/scheduler.py index 2432c9c..4a41839 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -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: diff --git a/app/series_view.py b/app/series_view.py new file mode 100644 index 0000000..ee2a9e1 --- /dev/null +++ b/app/series_view.py @@ -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 diff --git a/app/static/script.js b/app/static/script.js index 099ccb5..384fdb9 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -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 ? `${intermissionLabel(period)}` - : `${ordinalPeriod(period)}`; + : playoffOT + ? `${periodText} · SUDDEN DEATH` + : `${periodText}`; const dot = running ? `` : ''; - // 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) { ` : ''; - return ` -
+ 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, ` +
+ ${playoffContext(game)}
- ${periodLabel} + ${periodBadge} ${time} ${ppBadge(game)}
@@ -76,12 +158,17 @@ function renderLiveGame(game) { ${teamRow(game, 'Away', 'live')} ${teamRow(game, 'Home', 'live')} ${hype} -
`; + ${seriesBlurb(game)} +
`); } -function renderPreGame(game) { - return ` -
+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, ` +
+ ${playoffContext(game)}
${game['Start Time']} @@ -89,14 +176,19 @@ function renderPreGame(game) {
${teamRow(game, 'Away', 'pre')} ${teamRow(game, 'Home', 'pre')} -
`; + ${seriesBlurb(game)} +
`); } -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 ` -
+ const isPlayoff = game['Is Playoff']; + const playoffClass = isPlayoff ? ' game-box-playoff' : ''; + const pinnedClass = opts.pinned ? ' game-box-pinned' : ''; + return wrapSeriesLink(game, ` +
+ ${playoffContext(game)}
${label} @@ -104,7 +196,42 @@ function renderFinalGame(game) {
${teamRow(game, 'Away', 'final')} ${teamRow(game, 'Home', 'final')} -
`; + ${seriesBlurb(game)} +
`); +} + +function wrapSeriesLink(game, html) { + const sid = game['Series ID']; + if (!sid) return html; + return `${html}`; +} + +// ── Playoff context (badges row + series summary) ───── + +function playoffContext(game) { + if (!game['Is Playoff']) return ''; + const badges = (game['Series Badges'] || []) + .map(b => `${b}`) + .join(''); + const summary = game['Series Summary'] + ? `${game['Series Summary']}` + : ''; + if (!badges && !summary) return ''; + return `
${badges}${summary}
`; +} + +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 `
${game['Series Blurb']}
`; } // ── Team Row ───────────────────────────────────────── @@ -148,6 +275,10 @@ function ppBadge(game) { return `PP ${team} ${timeStr}`; } +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) { diff --git a/app/static/styles.css b/app/static/styles.css index 8d100a4..d18cb6f 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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/) ──────────── */ + +.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); +} + diff --git a/app/static/sw.js b/app/static/sw.js index 9b4034a..b658b1c 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -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 => { diff --git a/app/templates/_bracket_matchup.html b/app/templates/_bracket_matchup.html new file mode 100644 index 0000000..be5457d --- /dev/null +++ b/app/templates/_bracket_matchup.html @@ -0,0 +1,21 @@ +{% if m.empty %} +
+
TBD
+
TBD
+
+{% else %} + +
+ {% if m.top.logo %}{% endif %} + {{ m.top.abbrev }} + {% if m.top.seed %}{{ m.top.seed }}{% endif %} + {{ m.top_wins }} +
+
+ {% if m.bottom.logo %}{% endif %} + {{ m.bottom.abbrev }} + {% if m.bottom.seed %}{{ m.bottom.seed }}{% endif %} + {{ m.bottom_wins }} +
+
+{% endif %} diff --git a/app/templates/bracket.html b/app/templates/bracket.html new file mode 100644 index 0000000..f2eee7d --- /dev/null +++ b/app/templates/bracket.html @@ -0,0 +1,82 @@ + + + + {{ bracket.year }} Stanley Cup Bracket + + + + + + + +
+ ← NHL Scoreboard +
+
+
+

{{ bracket.year }} Stanley Cup Playoffs

+
The road to 16 wins
+
+ + {# Desktop: 7-column grid (East R1 | R2 | CF | Cup | CF | R2 | R1 West) #} +
+
+

First Round

+ {% for m in bracket.east_r1 %}{% include "_bracket_matchup.html" %}{% endfor %} +
+
+

Second Round

+ {% for m in bracket.east_r2 %}{% include "_bracket_matchup.html" %}{% endfor %} +
+
+

East Final

+ {% for m in bracket.east_cf %}{% include "_bracket_matchup.html" %}{% endfor %} +
+
+

Cup Final

+ {% for m in bracket.cup %}{% include "_bracket_matchup.html" %}{% endfor %} +
+
+

West Final

+ {% for m in bracket.west_cf %}{% include "_bracket_matchup.html" %}{% endfor %} +
+
+

Second Round

+ {% for m in bracket.west_r2 %}{% include "_bracket_matchup.html" %}{% endfor %} +
+
+

First Round

+ {% for m in bracket.west_r1 %}{% include "_bracket_matchup.html" %}{% endfor %} +
+
+ + {# Mobile: round-by-round accordion, round 1 open by default #} +
+ {% for rnd in bracket.rounds %} +
+ {{ rnd.label }} +
+ {% if rnd.get('east') %} +
+

Eastern

+ {% for m in rnd.east %}{% include "_bracket_matchup.html" %}{% endfor %} +
+ {% endif %} + {% if rnd.get('west') %} +
+

Western

+ {% for m in rnd.west %}{% include "_bracket_matchup.html" %}{% endfor %} +
+ {% endif %} + {% if rnd.get('cup') %} +
+ {% for m in rnd.cup %}{% include "_bracket_matchup.html" %}{% endfor %} +
+ {% endif %} +
+
+ {% endfor %} +
+
+ + diff --git a/app/templates/index.html b/app/templates/index.html index 4a66b9a..37b8e66 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -16,7 +16,42 @@
NHL Scoreboard
+
+