fix: anchor Day N to each round's first game instead of lazy first sighting
CI / Lint (push) Successful in 5s
CI / Test (push) Successful in 9s
CI / Build & Push (push) Successful in 19s

The banner read "Day 1 of ~60" on day 2 of the playoffs because the old
anchor recorded whatever date we first polled a playoff game as Day 1.
Now round start dates come from /v1/schedule/playoff-series, so Day N
is authoritative and resets at each round boundary. Drops the noisy
"of ~60" denominator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 13:03:08 -04:00
parent 930247b32f
commit a88e2edef0
7 changed files with 220 additions and 94 deletions
+3 -5
View File
@@ -173,15 +173,15 @@ def ot_label(period):
return "OT" if n == 1 else f"{n}OT"
def today_meta(raw_games, now=None, day_n=None, day_total=None):
def today_meta(raw_games, now=None, day_n=None):
"""Build the banner payload from the raw NHL games list.
`raw_games` is the list inside the NHL score response (each with gameType,
seriesStatus, etc.) — NOT the parsed game dicts. This keeps the dependency
one-way: playoff.py doesn't need to know parse_games' field names.
`day_n` / `day_total` are injected by the caller (from the playoff_cache
module) to keep this function pure and test-friendly.
`day_n` is injected by the caller (from the playoff_cache module) scoped to
the max observed round so the banner resets at each round boundary.
"""
playoff_games = [g for g in raw_games if g.get("gameType") == 3]
playoff_mode = len(playoff_games) > 0
@@ -191,7 +191,6 @@ def today_meta(raw_games, now=None, day_n=None, day_total=None):
"playoff_mode": False,
"round_label": None,
"day_n": None,
"day_total": None,
"series_active": 0,
"elimination_count": 0,
"game7_count": 0,
@@ -218,7 +217,6 @@ def today_meta(raw_games, now=None, day_n=None, day_total=None):
"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,
+68 -45
View File
@@ -93,7 +93,6 @@ def refresh_bracket(year=None):
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)
@@ -159,65 +158,89 @@ def fetch_series(series_id):
return None
# ── Playoff start date (drives the "Day N" banner) ─────────────────
# ── Per-round start dates (drive the "Day N" banner) ──────────────
META_KEY = "meta:first_playoff_date"
ROUND_DATES_KEY = "meta:round_start_dates"
def _maybe_record_start_date(bracket_payload):
"""Store the earliest scheduled round-1 series date as Day 1, once.
def refresh_round_start_dates(year=None):
"""Walk the cached bracket + per-series schedules; upsert per-round start dates.
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.
For each series in the cached bracket, fetches that series' schedule
(honoring the TTL cache) and computes the earliest Eastern game date
within the series. Aggregates to `min(startDate)` per playoffRound and
merges into the `meta:round_start_dates` cache entry.
Returns the full merged mapping {round_num_str: ISO date} or None if the
bracket isn't cached yet.
"""
# No-op for now; the scoreboard-driven path is authoritative.
return None
if year is None:
year = datetime.now(EASTERN).year
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:
bracket, _ = get_bracket(year)
if bracket is None:
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
existing, _ = _get(ROUND_DATES_KEY)
merged = dict(existing) if existing else {}
observed = {}
for series in bracket.get("series", []) or []:
letter = series.get("seriesLetter")
round_num = series.get("playoffRound")
if not letter or not round_num:
continue
payload = fetch_series(f"{year}-{letter}")
if not payload:
continue
for game in payload.get("games", []) or []:
start_utc = game.get("startTimeUTC")
if not start_utc:
continue
try:
local_date = (
datetime.fromisoformat(start_utc.replace("Z", "+00:00"))
.astimezone(EASTERN)
.date()
)
except ValueError:
continue
current = observed.get(round_num)
if current is None or local_date < current:
observed[round_num] = local_date
for round_num, start_date in observed.items():
merged[str(round_num)] = start_date.isoformat()
if merged:
_put(ROUND_DATES_KEY, merged)
return merged or None
def get_playoff_start_date():
payload, _ = _get(META_KEY)
if not payload or not payload.get("first_date"):
def get_round_start_date(round_num):
"""Return the Eastern date round `round_num` began, or None if unknown."""
payload, _ = _get(ROUND_DATES_KEY)
if not payload:
return None
iso = payload.get(str(round_num))
if not iso:
return None
try:
return date.fromisoformat(payload["first_date"])
return date.fromisoformat(iso)
except ValueError:
return None
def day_n(now=None):
"""Returns (day_n, day_total_estimate) or (None, None)."""
start = get_playoff_start_date()
def day_n_for_round(round_num, now=None):
"""Day number within a playoff round (Day 1 = round's first game date).
Returns the day number (>= 1) or None when the round hasn't been anchored.
"""
if round_num is None:
return None
start = get_round_start_date(round_num)
if start is None:
return None, None
return 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
n = (now.date() - start).days + 1
return n if n >= 1 else None
+15 -5
View File
@@ -8,11 +8,10 @@ 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,
day_n_for_round,
fetch_series,
get_bracket,
parse_series_id,
record_start_date_if_missing,
refresh_bracket,
)
from app.series_view import build_series_view
@@ -22,6 +21,17 @@ from zoneinfo import ZoneInfo
_EASTERN = ZoneInfo("America/New_York")
def _max_playoff_round(raw_games):
max_round = 0
for g in raw_games or []:
if g.get("gameType") != 3:
continue
r = (g.get("seriesStatus") or {}).get("round") or 0
if r > max_round:
max_round = r
return max_round or None
@app.route("/manifest.json")
def manifest():
return send_from_directory(app.static_folder, "manifest.json")
@@ -61,10 +71,10 @@ 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)
max_round = _max_playoff_round(raw_games)
n = day_n_for_round(max_round) if max_round else None
meta = today_meta(raw_games, day_n=n)
pinned = [g for g in games if g.get("Pinned")]
remaining = [g for g in games if not g.get("Pinned")]
+3 -1
View File
@@ -4,7 +4,7 @@ import time
import schedule
from app.api import refresh_scores
from app.playoff_cache import refresh_bracket
from app.playoff_cache import refresh_bracket, refresh_round_start_dates
from app.standings import refresh_standings
logger = logging.getLogger(__name__)
@@ -14,8 +14,10 @@ def start_scheduler():
schedule.every(600).seconds.do(refresh_standings)
schedule.every(10).seconds.do(refresh_scores)
schedule.every(3600).seconds.do(refresh_bracket)
schedule.every(21600).seconds.do(refresh_round_start_dates)
# Populate the cache once at startup so the banner has data immediately.
refresh_bracket()
refresh_round_start_dates()
logger.info("Background scheduler started")
while True:
try:
+1 -2
View File
@@ -58,8 +58,7 @@ function applyMeta(meta) {
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}`);
setText(dayEl, `Day ${meta.day_n}`);
dayEl.classList.remove('hidden');
} else {
dayEl.classList.add('hidden');