diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7886cd..cc9a0c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -699,6 +699,17 @@ policies are unchanged. heartbeat when the service stops), is `stale` at once instead of `live` for up to 180 s. No new files or writes: both checks are on the reading side. +- A scoreboard's scroll and Vegas cards with `scroll_card.date_format: + "weekday"` now show the printed date's own weekday. A Friday 8 PM ET game + read "Sat Oct 2". The card took the weekday in the plugin's own + `timezone` setting, which ships blank, so it fell back to UTC, while the + "Oct 2" beside it came from the zone the plugin actually resolves (its + setting, then the global one, then the system zone). Every zone is within a + day of UTC, so the card now finds which day near the start's UTC date has + the printed month and day and names that one. Games east of UTC (Auckland, + Kiritimati) were off by a day the other way and are fixed the same way. + The switch-mode scorebug, which already used the plugin's resolved zone, + shares the same formatter and draws what it drew before. - `/api/v3/display/current-status` reflects a wake from scheduled-off, a schedule-off blank, or an on-demand session starting or ending at once, even when the mode name stays the same. The display republished its diff --git a/src/common/sports_card.py b/src/common/sports_card.py index 0c3b7831..d33c4a36 100644 --- a/src/common/sports_card.py +++ b/src/common/sports_card.py @@ -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}" diff --git a/src/common/sports_shared.py b/src/common/sports_shared.py index c7a097f2..954a9b1f 100644 --- a/src/common/sports_shared.py +++ b/src/common/sports_shared.py @@ -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 ''.""" diff --git a/test/test_sports_card.py b/test/test_sports_card.py index f8de86f5..cafde765 100644 --- a/test/test_sports_card.py +++ b/test/test_sports_card.py @@ -13,6 +13,8 @@ body. That is what let all eight adopt this with byte-identical renders. import logging import json import os +from datetime import datetime, timezone +from zoneinfo import ZoneInfo import pytest @@ -169,6 +171,95 @@ class TestDateAndTime: assert C.card_tzinfo({"timezone": "Not/AZone"}, log) is timezone.utc +class TestWeekdayMatchesThePrintedDate: + """The weekday is the printed date's, whichever zone printed it. + + The extractor prints "M/D" in the plugin's resolved zone (its own + setting, else the global one, else the system zone). The card is handed + only the plugin's config, whose ``timezone`` ships as "" -- so a weekday + taken in card_tzinfo's zone was UTC's, and an evening kickoff in the + Americas read "Sat Oct 2" for a Friday game. + """ + + WEEKDAY = {"timezone": "", "scroll_card": {"date_format": "weekday"}} + + @staticmethod + def _as_printed(start_utc, zone): + """The game dict and the date text, as the extractor builds them.""" + local = datetime.fromisoformat(start_utc).astimezone(ZoneInfo(zone)) + game = {"start_time_utc": datetime.fromisoformat(start_utc), + "game_date": f"{local.month}/{local.day}"} + want = f"{C.WEEKDAY_ABBR[local.weekday()]} {C.MONTH_ABBR[local.month - 1]} {local.day}" + return game, want + + @pytest.mark.parametrize("start_utc, zone, want", [ + # Friday 8 PM EDT is Saturday in UTC. + ("2026-10-03T00:00:00+00:00", "America/New_York", "Fri Oct 2"), + # The night US clocks go back: 8:30 PM EDT Saturday, then 11 PM EST + # Sunday, each the next day in UTC. + ("2026-11-01T00:30:00+00:00", "America/New_York", "Sat Oct 31"), + ("2026-11-02T04:00:00+00:00", "America/New_York", "Sun Nov 1"), + # New Year's Eve on the west coast is New Year's Day in UTC. + ("2027-01-01T04:00:00+00:00", "America/Los_Angeles", "Thu Dec 31"), + # Just east of the date line: Pago Pago's Friday evening. + ("2026-10-03T05:00:00+00:00", "Pacific/Pago_Pago", "Fri Oct 2"), + # Just west of it, the other way: Saturday morning in Auckland is + # Friday in UTC -- and the 10 AM game on the day NZ clocks go forward. + ("2026-10-02T20:00:00+00:00", "Pacific/Auckland", "Sat Oct 3"), + ("2026-09-26T21:00:00+00:00", "Pacific/Auckland", "Sun Sep 27"), + # UTC+14, the furthest any zone sits from UTC. + ("2026-10-02T11:00:00+00:00", "Pacific/Kiritimati", "Sat Oct 3"), + # A zone on UTC's own date needs nothing. + ("2026-10-02T19:00:00+00:00", "Europe/London", "Fri Oct 2"), + ]) + def test_the_shipped_blank_timezone(self, log, start_utc, zone, want): + game, printed = self._as_printed(start_utc, zone) + assert printed == want # the case says what the extractor prints + assert C.format_game_date(self.WEEKDAY, log, game["game_date"], game) == want + + def test_an_iso_string_start_reads_the_same(self, log): + game = {"start_time_utc": "2026-10-03T00:00:00Z"} + assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Fri Oct 2" + assert C.format_game_date(self.WEEKDAY, log, "10/02", game) == "Fri Oct 2" + + def test_a_plugin_level_zone_still_agrees(self, log): + game, want = self._as_printed("2026-10-03T00:00:00+00:00", "America/Chicago") + cfg = dict(self.WEEKDAY, timezone="America/Chicago") + assert C.format_game_date(cfg, log, game["game_date"], game) == want == "Fri Oct 2" + + def test_a_date_no_zone_could_print_keeps_the_zone_weekday(self, log): + """More than a day from the start: nothing to anchor to, so the + weekday is card_tzinfo's, as it always was.""" + game = {"start_time_utc": datetime(2026, 10, 3, 0, 0, tzinfo=timezone.utc)} + assert C.format_game_date(self.WEEKDAY, log, "10/9", game) == "Sat Oct 9" + + def test_a_start_without_an_offset_keeps_the_zone_weekday(self, log): + """A naive time names no instant, so it cannot place the date.""" + game = {"start_time_utc": datetime(2026, 10, 2, 20, 0)} + assert C.format_game_date(self.WEEKDAY, log, "10/3", game) == \ + f"{C.weekday_for(self.WEEKDAY, log, game)} Oct 3" + + @pytest.mark.parametrize("game", [None, {}, {"start_time_utc": "garbage"}]) + def test_no_usable_start_draws_no_weekday(self, log, game): + assert C.format_game_date(self.WEEKDAY, log, "10/2", game) == "Oct 2" + + def test_the_scorebug_twin_formats_the_same(self, log): + """Switch mode (SportsCoreSharedMixin) shares the formatter body.""" + from src.common.sports_shared import SportsCoreSharedMixin + + class Host(SportsCoreSharedMixin): + config = {"scroll_card": {"date_format": "weekday", + "switch_date_format": "inherit"}} + logger = log + + def _get_timezone(self): + return ZoneInfo("America/New_York") + + game, want = self._as_printed("2026-11-01T00:30:00+00:00", "America/New_York") + assert Host()._format_game_date(game["game_date"], game) == want + assert C.format_game_date(Host.config, log, game["game_date"], game) == want + + class TestFontSizing: def test_snaps_to_the_faces_pixel_grid(self): assert C.crisp_size("4x6-font.ttf", 6) == 7 # 7px grid diff --git a/test/test_sports_twins.py b/test/test_sports_twins.py index 4202468d..32f5cc47 100644 --- a/test/test_sports_twins.py +++ b/test/test_sports_twins.py @@ -561,21 +561,24 @@ class TestPinnedDivergence: assert C.recent_score_color(on, LOG, game, (9, 9, 9)) == (9, 9, 9) def test_weekday_zone_source(self): - # DIVERGENCE, user-visible: the scorebug asks the plugin's + # DIVERGENCE, not drawn: the scorebug asks the plugin's # _get_timezone() (plugin setting -> global setting -> system zone); # the card reads only config["timezone"] and falls back to UTC. The # scoreboards' schemas default that key to "", and the scroll display - # hands the renderer the plugin config, so a board that sets only the - # global zone gets UTC weekdays in scroll mode: an evening kickoff in - # New York is labelled with the next day. + # hands the renderer the plugin config, so the bare weekday helpers + # still disagree for an evening kickoff in New York. game = {"start_time_utc": "2026-09-20T00:30:00+00:00"} # Sat 20:30 EDT host = _Host({}, tz=ZoneInfo("America/New_York")) assert host._weekday_for(game) == "Sat" assert C.weekday_for({}, LOG, game) == "Sun" + # DECIDED: what a card draws is the printed date's own weekday, so + # the scroll card no longer labels that kickoff with the next day + # ("Sun Sep 19" before). Both formatters place the extractor's "M/D" + # against the start time instead of re-deriving the day in a zone. cfg = {"scroll_card": {"date_format": "weekday", "switch_date_format": "inherit"}} host = _Host(cfg, tz=ZoneInfo("America/New_York")) assert host._format_game_date("9/19", game) == "Sat Sep 19" - assert C.format_game_date(cfg, LOG, "9/19", game) == "Sun Sep 19" + assert C.format_game_date(cfg, LOG, "9/19", game) == "Sat Sep 19" def test_weekday_out_of_range_start(self): # DIVERGENCE: the mixin catches OverflowError from astimezone() and