fix(sports): scroll and Vegas cards name the printed date's own weekday (#747)

With scroll_card.date_format "weekday", a Friday 8 PM ET game read
"Sat Oct 2" on the scroll and Vegas cards.

Cause: the extractor prints the "M/D" in the plugin's resolved zone (its
own setting, then the global one, then the system zone), but the card is
handed only the plugin's config. Its timezone ships as "", so
card_tzinfo fell back to UTC and the weekday belonged to the UTC date:
the next day for evening games in the Americas, the previous day for
morning games east of UTC (Auckland, Kiritimati).

Fix: every zone is within a day of UTC, so the printed date is the
start's UTC date or a neighbour of it. _format_date_as now takes the game
and names the weekday of whichever of those days has the printed month
and day, falling back to the zone-based weekday only when the start
cannot place the date (no offset, unparseable, or more than a day away).
The switch-mode scorebug shares the formatter and passes the game too, so
the twins stay identical; it already used the resolved zone and draws
what it drew before. Public signatures are unchanged.

Tests cover US DST end, New Year's Eve, both sides of the date line, NZ
DST start and UTC+14. The twins test's weekday pin is updated: the drawn
date now agrees, and only the bare weekday helpers still differ.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 22:19:05 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e32d177cbd
commit 07abd87d5e
5 changed files with 157 additions and 11 deletions
+43 -4
View File
@@ -18,7 +18,7 @@ the extra guard only stops a None size raising TypeError.
"""
import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple
from zoneinfo import ZoneInfo
@@ -338,10 +338,46 @@ def format_game_date(config: Optional[Dict[str, Any]], logger, date_text: str,
if not raw:
return ""
fmt = str(scroll_card_option(config, "date_format", "abbrev") or "abbrev")
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game))
return _format_date_as(fmt, raw, lambda: weekday_for(config, logger, game),
game=game)
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
def _printed_weekday(game: Optional[Dict], month: int, day: int) -> str:
"""The weekday of the date a card prints as month/day, or '' if unknown.
The extractor prints "M/D" in the plugin's resolved zone (its own setting,
else the global one, else the system zone). The card cannot see that zone:
it is handed the plugin's config, whose ``timezone`` ships as "", so
card_tzinfo answers UTC and an evening kickoff in the Americas got the
next day's weekday ("Sat Oct 2" for a Friday game). Every zone is within
a day of UTC, so the printed date is the start's UTC date or a neighbour
of it; the one with that month and day is the date on the card.
"""
if not isinstance(game, dict):
return ""
raw = game.get("start_time_utc") or game.get("start_time")
if not raw:
return ""
try:
start = raw if isinstance(raw, datetime) else datetime.fromisoformat(
str(raw).replace("Z", "+00:00"))
if start.utcoffset() is None:
return "" # naive: no instant to place the date against
utc_day = start.astimezone(timezone.utc).date()
except (ValueError, TypeError, OverflowError):
return ""
for offset in (0, -1, 1):
try:
candidate = utc_day + timedelta(days=offset)
except OverflowError:
continue
if (candidate.month, candidate.day) == (month, day):
return WEEKDAY_ABBR[candidate.weekday()]
return ""
def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR,
game: Optional[Dict] = None) -> str:
"""Render a stripped, non-empty "M/D" *raw* in style *fmt*.
The body both date formatters share. They differ in which setting names the
@@ -349,6 +385,9 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
``SportsCoreSharedMixin._format_game_date``), so those arrive as arguments:
*weekday* is a zero-argument callable, only called for the "weekday" style.
*months* lets the mixin keep reading its (overridable) ``_MONTH_ABBR``.
With *game*, the "weekday" style names the printed date's own weekday
(:func:`_printed_weekday`), and *weekday* is only the fallback for a
date its start time cannot place.
"""
if fmt == "numeric":
return raw
@@ -364,7 +403,7 @@ def _format_date_as(fmt: str, raw: str, weekday, months=MONTH_ABBR) -> str:
if fmt == "day_first":
return f"{day} {name}"
if fmt == "weekday":
day_name = weekday()
day_name = _printed_weekday(game, month, day) or weekday()
return f"{day_name} {name} {day}" if day_name else f"{name} {day}"
return f"{name} {day}"
+4 -2
View File
@@ -360,14 +360,16 @@ class SportsCoreSharedMixin:
The formatting is sports_card's. What differs from the card's
``format_game_date`` is passed in: the setting (``switch_date_format``,
see :meth:`_switch_date_format`) and the weekday, which comes from
:meth:`_weekday_for` and so from this plugin's resolved timezone.
:meth:`_weekday_for` and so from this plugin's resolved timezone
when the game's start cannot place the printed date. The game goes
in too, so both formatters name the printed date's own weekday.
"""
raw = str(date_text or "").strip()
if not raw:
return raw
return _card._format_date_as(self._switch_date_format(), raw,
lambda: self._weekday_for(game),
self._MONTH_ABBR)
self._MONTH_ABBR, game=game)
def _weekday_for(self, game: Optional[Dict]) -> str:
"""Weekday abbreviation from the game's start time, or ''."""