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 <noreply@anthropic.com>
This commit is contained in:
+68
-45
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user