From 35dd7f43c5cfa185fde1e773f22df1bec1413422 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 21:17:58 -0400 Subject: [PATCH] fix(soccer): look two weeks ahead for fixtures, matching the real fetch Reported by a user: Manchester United never appeared even though their next fixture was 22 August, and with favourites turned off the board showed exactly one Premier League game, Arsenal v Coventry. _get_weeks_data() is the partial that serves the display until the background fetch lands. It looked ahead seven days; _fetch_soccer_api_data(), the fetch it substitutes for, looks ahead fourteen. So a fixture inside the real window was simply missing from the board. That gap is invisible in a league that plays daily and severe in one that plays weekly, where a whole matchweek can fall inside it. Reproduced against ESPN on 2026-08-14: -2w..+1w 20260731-20260821 -> 1 event (COV @ ARS, the 21st) -2w..+4w 20260731-20260911 -> 30 events (MAN @ HUL on the 22nd, ...) The Premier League's opening matchweek was 21-24 August, so a +7d horizon caught the Friday opener and hid the other nine fixtures -- exactly the single game the user described. Both horizons now come from one pair of module constants, so the partial cannot silently end up narrower than the fetch it stands in for again. The test reads the full fetch's span out of soccer_managers.py and requires the partial to be at least as wide, rather than hard-coding either number. Mutation-checked: reverting the horizon, re-hardcoding the window in _get_weeks_data, and narrowing the back-window are all caught. Harness clean; soccer's other tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 2 +- plugins/soccer-scoreboard/manifest.json | 8 +- plugins/soccer-scoreboard/sports.py | 20 +++- .../test_schedule_horizon.py | 112 ++++++++++++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 plugins/soccer-scoreboard/test_schedule_horizon.py diff --git a/plugins.json b/plugins.json index c669b646..362c3b44 100644 --- a/plugins.json +++ b/plugins.json @@ -760,7 +760,7 @@ "last_updated": "2026-08-13", "verified": true, "screenshot": "", - "latest_version": "2.10.4" + "latest_version": "2.10.5" }, { "id": "static-image", diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index 8b31d4c6..ca14be73 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.10.4", + "version": "2.10.5", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.10.5", + "released": "2026-08-14", + "ledmatrix_min_version": "2.0.0", + "notes": "Show fixtures up to two weeks out instead of one. The partial fetch that serves the display until the background fetch lands looked ahead seven days, while the full fetch it stands in for looks ahead fourteen, so a fixture inside the real window was missing from the board. Invisible in a league that plays daily and severe in one that plays weekly, where a whole matchweek can fall in the gap: on 14 August the Premier League's opening matchweek was 21-24 August, so the board showed exactly one game (Arsenal v Coventry on the 21st) and hid the other nine, including Manchester United on the 22nd. Reported by a user whose favourite team never appeared while other clubs did. Both horizons now come from one pair of constants so they cannot drift apart again." + }, { "version": "2.10.4", "released": "2026-08-14", diff --git a/plugins/soccer-scoreboard/sports.py b/plugins/soccer-scoreboard/sports.py index 9f56c7d1..f2b12f2d 100644 --- a/plugins/soccer-scoreboard/sports.py +++ b/plugins/soccer-scoreboard/sports.py @@ -87,6 +87,14 @@ def _resolve_font_path(path: str) -> str: +# How far either side of now the schedule is fetched. The partial fetch that +# serves the display until the background fetch lands must not be narrower +# than the fetch it substitutes for, or a game inside the real window is +# missing from the panel until that completes. +_SCHEDULE_WINDOW_BACK = timedelta(days=14) +_SCHEDULE_WINDOW_FORWARD = timedelta(days=14) + + class SportsCore(ABC): def __init__( self, @@ -1316,8 +1324,16 @@ def _get_weeks_data(self) -> Optional[Dict]: now = datetime.now(pytz.utc) immediate_events = [] - start_date = now + timedelta(weeks=-2) - end_date = now + timedelta(weeks=1) + # Same horizon as the full fetch this stands in for + # (_fetch_soccer_api_data, -14d..+14d). It used to end a week + # earlier, which is invisible in a league that plays daily and + # severe in one that plays weekly: on 2026-08-14 the Premier + # League's opening matchweek was 21-24 August, so a +7d horizon + # returned exactly one fixture (COV @ ARS on the 21st) and hid the + # other nine, including Man Utd on the 22nd. Reported as a + # favourite team never appearing while other clubs did. + start_date = now - _SCHEDULE_WINDOW_BACK + end_date = now + _SCHEDULE_WINDOW_FORWARD date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" response = self.session.get( diff --git a/plugins/soccer-scoreboard/test_schedule_horizon.py b/plugins/soccer-scoreboard/test_schedule_horizon.py new file mode 100644 index 00000000..022886d6 --- /dev/null +++ b/plugins/soccer-scoreboard/test_schedule_horizon.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Tests that the partial schedule fetch covers the same span as the full one. + +Reported by a user: Manchester United never appeared even though their next +fixture was 22 August, and with favourites turned off the board showed exactly +one Premier League game -- Arsenal v Coventry. + +Reproduced against ESPN on 2026-08-14: + + -2w..+1w 20260731-20260821 -> 1 event (COV @ ARS on the 21st) + -2w..+4w 20260731-20260911 -> 30 events (MAN @ HUL on the 22nd, ...) + +_get_weeks_data() is the partial that serves the display until the background +fetch lands, and it ended a week earlier than _fetch_soccer_api_data(), the +fetch it substitutes for. Invisible in a league that plays daily; severe in one +that plays weekly, where a whole matchweek can sit in the gap. The Premier +League's opening matchweek was 21-24 August, so a +7d horizon caught the Friday +opener and hid the other nine fixtures. + +Run: /bin/python plugins/soccer-scoreboard/test_schedule_horizon.py +""" + +import ast +import sys +from datetime import datetime, timedelta +from pathlib import Path + +plugin_dir = Path(__file__).parent +sys.path.insert(0, str(plugin_dir)) +for candidate in (Path("/home/rackpi/projects/LEDMatrix"), + plugin_dir.parents[2] / "LEDMatrix"): + if (candidate / "src" / "plugin_system" / "base_plugin.py").exists(): + sys.path.insert(0, str(candidate)) + break + +import sports # noqa: E402 + +failures = [] + + +def check(label, ok): + print((" PASS " if ok else " FAIL ") + label) + if not ok: + failures.append(label) + + +def main(): + print("the partial fetch is not narrower than the fetch it stands in for") + # The full fetch's span, read from soccer_managers so the two cannot drift + # apart silently: that file is where the background fetch is built. + managers_src = (plugin_dir / "soccer_managers.py").read_text(encoding="utf-8") + tree = ast.parse(managers_src) + full = next((n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) + and n.name == "_fetch_soccer_api_data"), None) + check("_fetch_soccer_api_data exists", full is not None) + + deltas = [] + for node in ast.walk(full): + if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "timedelta": + for kw in node.keywords: + if isinstance(kw.value, ast.Constant): + deltas.append((kw.arg, kw.value.value)) + full_days = max((v for unit, v in deltas if unit == "days"), default=None) + check("its horizon is discoverable (%s)" % deltas, full_days is not None) + + forward = sports._SCHEDULE_WINDOW_FORWARD + back = sports._SCHEDULE_WINDOW_BACK + check("the partial's forward horizon is a timedelta", + isinstance(forward, timedelta)) + if full_days is not None: + check("partial forward (%dd) >= full forward (%dd)" + % (forward.days, full_days), forward.days >= full_days) + check("partial back (%dd) >= full back (%dd)" + % (back.days, full_days), back.days >= full_days) + + print("\nthe reported fixture falls inside the horizon") + # The user's case, as dates rather than a live API call so the test stays + # deterministic: on 2026-08-14, Man Utd played on the 22nd. + today = datetime(2026, 8, 14) + fixture = datetime(2026, 8, 22) + check("a fixture 8 days out is covered", today + forward >= fixture) + check("...and was not, at the old +7d horizon", + today + timedelta(days=7) < fixture) + + print("\nthe whole opening matchweek is covered, not just its first day") + # 21-24 August. Catching only the Friday game is what produced + # "the only game it's showing is Arsenal vs. Coventry". + matchweek_end = datetime(2026, 8, 24) + check("the matchweek's last day is inside the horizon", + today + forward >= matchweek_end) + + print("\n_get_weeks_data uses the constants rather than its own numbers") + src = (plugin_dir / "sports.py").read_text(encoding="utf-8") + fn = next((n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.FunctionDef) + and n.name == "_get_weeks_data"), None) + check("_get_weeks_data exists", fn is not None) + literal_deltas = [n for n in ast.walk(fn) + if isinstance(n, ast.Call) + and getattr(n.func, "id", "") == "timedelta"] + check("no hard-coded window remains in it", not literal_deltas) + names = {n.id for n in ast.walk(fn) if isinstance(n, ast.Name)} + check("it references both constants", + {"_SCHEDULE_WINDOW_BACK", "_SCHEDULE_WINDOW_FORWARD"} <= names) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())