From a88e2edef04eba57dfebc82f478794b6b51196a2 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 19 Apr 2026 13:03:08 -0400 Subject: [PATCH] fix: anchor Day N to each round's first game instead of lazy first sighting 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 --- app/playoff.py | 8 +-- app/playoff_cache.py | 113 ++++++++++++++++++------------- app/routes.py | 20 ++++-- app/scheduler.py | 4 +- app/static/script.js | 3 +- tests/test_playoff_cache.py | 128 +++++++++++++++++++++++++++--------- tests/test_scheduler.py | 38 +++++++++-- 7 files changed, 220 insertions(+), 94 deletions(-) diff --git a/app/playoff.py b/app/playoff.py index aa7c418..c49eda3 100644 --- a/app/playoff.py +++ b/app/playoff.py @@ -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, diff --git a/app/playoff_cache.py b/app/playoff_cache.py index aafbca5..4eea1df 100644 --- a/app/playoff_cache.py +++ b/app/playoff_cache.py @@ -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 diff --git a/app/routes.py b/app/routes.py index 4f58763..7c0faa5 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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")] diff --git a/app/scheduler.py b/app/scheduler.py index 4a41839..5afb2c6 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -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: diff --git a/app/static/script.js b/app/static/script.js index 384fdb9..49b30da 100644 --- a/app/static/script.js +++ b/app/static/script.js @@ -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'); diff --git a/tests/test_playoff_cache.py b/tests/test_playoff_cache.py index a923f4e..f6fe6c5 100644 --- a/tests/test_playoff_cache.py +++ b/tests/test_playoff_cache.py @@ -144,46 +144,112 @@ class TestFetchSeries: assert playoff_cache.fetch_series("2026-A") is None -class TestRecordStartDate: - def test_no_playoff_games_no_write(self, tmp_db): - result = playoff_cache.record_start_date_if_missing([{"gameType": 2}]) - assert result is None - assert playoff_cache.get_playoff_start_date() is None +class TestRefreshRoundStartDates: + def test_no_bracket_returns_none(self, tmp_db): + assert playoff_cache.refresh_round_start_dates(2026) is None - def test_records_on_first_playoff_sighting(self, tmp_db): - now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) - result = playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=now) - assert result == "2026-04-18" - assert playoff_cache.get_playoff_start_date().isoformat() == "2026-04-18" - - def test_idempotent_after_first_write(self, tmp_db): - first_now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) - second_now = datetime(2026, 4, 25, 20, 0, tzinfo=EASTERN) - playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=first_now) - # Second call should not overwrite - result = playoff_cache.record_start_date_if_missing( - [{"gameType": 3}], now=second_now + def test_anchors_round_to_earliest_game(self, tmp_db, monkeypatch): + playoff_cache._put( + playoff_cache.bracket_key(2026), + {"series": [{"seriesLetter": "A", "playoffRound": 1}]}, ) - assert result == "2026-04-18" + series_payload = { + "games": [ + {"startTimeUTC": "2026-04-19T23:00:00Z"}, + {"startTimeUTC": "2026-04-18T23:00:00Z"}, + {"startTimeUTC": "2026-04-21T23:00:00Z"}, + ] + } + monkeypatch.setattr( + "app.playoff_cache.requests.get", + lambda *a, **kw: _Resp(series_payload), + ) + + merged = playoff_cache.refresh_round_start_dates(2026) + assert merged == {"1": "2026-04-18"} + assert playoff_cache.get_round_start_date(1).isoformat() == "2026-04-18" + + def test_merges_multiple_rounds_min_per_round(self, tmp_db, monkeypatch): + playoff_cache._put( + playoff_cache.bracket_key(2026), + { + "series": [ + {"seriesLetter": "A", "playoffRound": 1}, + {"seriesLetter": "B", "playoffRound": 1}, + {"seriesLetter": "I", "playoffRound": 2}, + ] + }, + ) + payloads = { + "A": {"games": [{"startTimeUTC": "2026-04-19T23:00:00Z"}]}, + "B": {"games": [{"startTimeUTC": "2026-04-18T23:00:00Z"}]}, + "I": {"games": [{"startTimeUTC": "2026-04-29T23:00:00Z"}]}, + } + + def fake_get(url, *a, **kw): + letter = url.rstrip("/").rsplit("/", 1)[-1].upper() + return _Resp(payloads[letter]) + + monkeypatch.setattr("app.playoff_cache.requests.get", fake_get) + + merged = playoff_cache.refresh_round_start_dates(2026) + assert merged == {"1": "2026-04-18", "2": "2026-04-29"} + + def test_preserves_existing_rounds_on_merge(self, tmp_db, monkeypatch): + playoff_cache._put( + playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18", "2": "2026-04-29"} + ) + playoff_cache._put( + playoff_cache.bracket_key(2026), + {"series": [{"seriesLetter": "M", "playoffRound": 3}]}, + ) + monkeypatch.setattr( + "app.playoff_cache.requests.get", + lambda *a, **kw: _Resp( + {"games": [{"startTimeUTC": "2026-05-15T23:00:00Z"}]} + ), + ) + + merged = playoff_cache.refresh_round_start_dates(2026) + assert merged["1"] == "2026-04-18" + assert merged["2"] == "2026-04-29" + assert merged["3"] == "2026-05-15" -class TestDayN: - def test_no_start_date(self, tmp_db): - assert playoff_cache.day_n() == (None, None) +class TestDayNForRound: + def test_no_round_num(self, tmp_db): + assert playoff_cache.day_n_for_round(None) is None + + def test_round_not_anchored(self, tmp_db): + assert playoff_cache.day_n_for_round(1) is None def test_day_one(self, tmp_db): + playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"}) now = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) - playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=now) - n, total = playoff_cache.day_n(now=now) - assert n == 1 - assert total == 60 + assert playoff_cache.day_n_for_round(1, now=now) == 1 + + def test_day_two(self, tmp_db): + playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"}) + now = datetime(2026, 4, 19, 10, 0, tzinfo=EASTERN) + assert playoff_cache.day_n_for_round(1, now=now) == 2 def test_day_five(self, tmp_db): - start = datetime(2026, 4, 18, 20, 0, tzinfo=EASTERN) - later = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN) - playoff_cache.record_start_date_if_missing([{"gameType": 3}], now=start) - n, _ = playoff_cache.day_n(now=later) - assert n == 5 + playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"1": "2026-04-18"}) + now = datetime(2026, 4, 22, 20, 0, tzinfo=EASTERN) + assert playoff_cache.day_n_for_round(1, now=now) == 5 + + def test_round_two_resets_to_day_one(self, tmp_db): + playoff_cache._put( + playoff_cache.ROUND_DATES_KEY, + {"1": "2026-04-18", "2": "2026-04-29"}, + ) + now = datetime(2026, 4, 29, 20, 0, tzinfo=EASTERN) + assert playoff_cache.day_n_for_round(2, now=now) == 1 + + def test_before_start_returns_none(self, tmp_db): + playoff_cache._put(playoff_cache.ROUND_DATES_KEY, {"2": "2026-04-29"}) + now = datetime(2026, 4, 20, 20, 0, tzinfo=EASTERN) + assert playoff_cache.day_n_for_round(2, now=now) is None class TestSchema: diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 9f5cc59..c893d96 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -3,10 +3,15 @@ import pytest from app.scheduler import start_scheduler +def _patch_eager(mocker): + mocker.patch("app.scheduler.refresh_bracket") + mocker.patch("app.scheduler.refresh_round_start_dates") + + class TestStartScheduler: def test_registers_standings_refresh_every_600_seconds(self, mocker): mock_schedule = mocker.patch("app.scheduler.schedule") - mocker.patch("app.scheduler.refresh_bracket") + _patch_eager(mocker) mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) with pytest.raises(StopIteration): @@ -17,7 +22,7 @@ class TestStartScheduler: def test_registers_score_refresh_every_10_seconds(self, mocker): mock_schedule = mocker.patch("app.scheduler.schedule") - mocker.patch("app.scheduler.refresh_bracket") + _patch_eager(mocker) mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) with pytest.raises(StopIteration): @@ -28,7 +33,7 @@ class TestStartScheduler: def test_registers_bracket_refresh_every_3600_seconds(self, mocker): mock_schedule = mocker.patch("app.scheduler.schedule") - mocker.patch("app.scheduler.refresh_bracket") + _patch_eager(mocker) mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) with pytest.raises(StopIteration): @@ -37,9 +42,32 @@ class TestStartScheduler: intervals = [call[0][0] for call in mock_schedule.every.call_args_list] assert 3600 in intervals + def test_registers_round_start_dates_refresh_every_21600_seconds(self, mocker): + mock_schedule = mocker.patch("app.scheduler.schedule") + _patch_eager(mocker) + mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) + + with pytest.raises(StopIteration): + start_scheduler() + + intervals = [call[0][0] for call in mock_schedule.every.call_args_list] + assert 21600 in intervals + def test_invokes_bracket_refresh_eagerly_at_startup(self, mocker): mocker.patch("app.scheduler.schedule") eager = mocker.patch("app.scheduler.refresh_bracket") + mocker.patch("app.scheduler.refresh_round_start_dates") + mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) + + with pytest.raises(StopIteration): + start_scheduler() + + assert eager.called + + def test_invokes_round_start_dates_refresh_eagerly_at_startup(self, mocker): + mocker.patch("app.scheduler.schedule") + mocker.patch("app.scheduler.refresh_bracket") + eager = mocker.patch("app.scheduler.refresh_round_start_dates") mocker.patch("app.scheduler.time.sleep", side_effect=StopIteration) with pytest.raises(StopIteration): @@ -49,7 +77,7 @@ class TestStartScheduler: def test_runs_pending_on_each_tick(self, mocker): mock_schedule = mocker.patch("app.scheduler.schedule") - mocker.patch("app.scheduler.refresh_bracket") + _patch_eager(mocker) call_count = {"n": 0} def sleep_twice(_): @@ -66,7 +94,7 @@ class TestStartScheduler: def test_continues_after_exception_in_run_pending(self, mocker): mock_schedule = mocker.patch("app.scheduler.schedule") - mocker.patch("app.scheduler.refresh_bracket") + _patch_eager(mocker) call_count = {"n": 0} def raise_then_stop(_):