From 439013b18c26948559858710c4addb9849b480eb Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:49:36 -0400 Subject: [PATCH] fix(common): favourite check no longer calls the Europa League finished between matchdays (#670) On 2026-09-29 ESPN's uefa.europa scoreboard still showed the 17 September matchday, so every event was past. Its calendar is a "list" of rounds (League Phase to 30 Jan 2027, then the knockout rounds to the final), not a match-day whitelist, and the league's season type is a soccer id rather than 2/3, so neither 3.6.1 rule applied and the check said the season had finished. When every event is past, a round in a list calendar that has not started yet now draws no conclusion. Only a round's start date counts: end dates are padded past the last game (AFL's Grand Final round still had a day to run three days after the Grand Final), and rounds in an offseason phase (college football's All-Star week) are skipped. Season end dates are still ignored, so PLL (season to 2027-01-01) stays "finished", as do the World Cup and AFL. Of 28 live ESPN scoreboards only uefa.europa's message changes. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++ src/common/favorite_team_check.py | 40 +++++++++++++ test/test_favorite_team_check.py | 98 +++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b65d85..69d12dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Fixes + +- The favourite-team check no longer says the Europa League season has + finished between matchdays. Its scoreboard keeps showing the last matchday, + and its calendar is a "list" of rounds rather than match days, so neither + 3.6.1 rule applied. When every event is past, a round in a list calendar + that has not started yet (outside an offseason phase) now draws no + conclusion. PLL, the World Cup and AFL, whose seasons are over, are still + reported as finished: no round of theirs is still to start. + ## 3.6.1 A fix to `src.common.favorite_team_check` (#667). Plugins that drop their diff --git a/src/common/favorite_team_check.py b/src/common/favorite_team_check.py index 7695c28d..b3470115 100644 --- a/src/common/favorite_team_check.py +++ b/src/common/favorite_team_check.py @@ -218,6 +218,8 @@ class FavoriteTeamCheck: return None # Nothing published either way; draw no conclusion. if cls._moved_to_later_phase(payload): return None # e.g. postseason under way; see the method. + if cls._later_round_scheduled(payload, now): + return None # e.g. Europa League between matchdays. return ("the season has finished and the next one's fixtures are " "not published yet") @@ -259,6 +261,44 @@ class FavoriteTeamCheck: known = [t for t in event_types if isinstance(t, int)] return bool(known) and all(t < league_type for t in known) + @classmethod + def _later_round_scheduled(cls, payload, now: datetime) -> bool: + """ + Whether a "list" calendar has a round that has not started yet. + + Competitions with a list calendar (the UEFA club competitions, the + World Cup, AFL, NFL) give each phase its rounds as ``entries`` with + start and end dates. Between matchdays the Europa League scoreboard + keeps showing the last one: on 2026-09-29 every event was from 17 + September, the next matchday was only days away, and the rounds from + the knockout play-offs to the final were all still to come. A round + that starts later means the season is not over, even though the + date of the next fixture is not known. + + Only a round's *start* counts. End dates are padded well past the + last game -- the World Cup's final round ran to 1 August for a 19 July + final -- so a future end date is also true of a finished season. + Rounds in an offseason phase (the college football All-Star week) + are not games for the favourites and do not count either. + """ + league = (payload.get('leagues') or [{}])[0] or {} + for phase in league.get('calendar') or []: + if not isinstance(phase, dict) or cls._is_offseason(phase.get('label')): + continue + for entry in phase.get('entries') or []: + if not isinstance(entry, dict) or cls._is_offseason(entry.get('label')): + continue + start = cls._parse_date(entry.get('startDate')) + if start and start > now: + return True + return False + + @staticmethod + def _is_offseason(label) -> bool: + """'Off Season', 'Offseason', 'Off-season' ...""" + return isinstance(label, str) and 'offseason' in re.sub( + r'[^a-z]', '', label.lower()) + @staticmethod def _parse_date(raw) -> Optional[datetime]: if not raw or not isinstance(raw, str): diff --git a/test/test_favorite_team_check.py b/test/test_favorite_team_check.py index 35ef30ad..6d5d14f4 100644 --- a/test/test_favorite_team_check.py +++ b/test/test_favorite_team_check.py @@ -439,3 +439,101 @@ class ScheduleNoteMatchdayTests(unittest.TestCase): # without games, so a future entry there is not a next fixture. note = self.note(self.payload([-9], [11], whitelist=False)) self.assertIn("season has finished", note) + + +class ScheduleNoteListCalendarTests(unittest.TestCase): + """A round still to start in a "list" calendar is not a finished season. + + Shapes captured from ESPN on 2026-09-29, with dates kept relative to that + day. The Europa League scoreboard still showed the 17 September matchday + and its calendar is a ``"list"`` of rounds, not match days, so the check + said the season had finished -- with the knockout rounds, and the next + league-phase matchday, still to come. PLL, the World Cup and AFL really had + finished and must still say so, although each has a season or round + ``endDate`` in the future. + """ + + note = ScheduleNoteTests.note + + @staticmethod + def iso(days): + return (datetime.now(timezone.utc) + timedelta(days=days)).strftime( + "%Y-%m-%dT%H:%MZ") + + @classmethod + def list_league(cls, event_days, rounds, league_type=14540, + event_type=14540, phase_label="UEFA Europa League", + extra_phases=()): + """``rounds`` is ``[(label, start_day, end_day), ...]`` for one phase.""" + return { + "events": [{"date": cls.iso(d), "season": {"type": event_type}} + for d in event_days], + "leagues": [{ + "season": {"type": {"type": league_type}}, + "calendarType": "list", + "calendarIsWhitelist": True, + "calendar": [{ + "label": phase_label, + "startDate": cls.iso(-90), "endDate": cls.iso(275), + "entries": [{"label": label, "startDate": cls.iso(start), + "endDate": cls.iso(end)} + for label, start, end in rounds], + }] + list(extra_phases), + }], + } + + def test_europa_between_matchdays_is_not_finished(self): + note = self.note(self.list_league([-12], [ + ("League Phase", -31, 123), + ("Knockout Round Playoffs", 123, 151), + ("Rd of 16", 151, 172), + ("Quarterfinals", 172, 200), + ("Semifinals", 200, 221), + ("Final", 222, 275), + ])) + self.assertIsNone(note) + + def test_world_cup_after_the_final_is_still_finished(self): + # The competition runs to 31 December, and the last round ended 12 + # days after the final; no round is still to start. + note = self.note(self.list_league([-72], [ + ("Group", -110, -93), + ("Semifinals", -77, -72), + ("Final", -72, -59), + ], league_type=13803, event_type=13803, phase_label="FIFA World Cup")) + self.assertIn("season has finished", note) + + def test_afl_after_the_grand_final_is_still_finished(self): + # The Grand Final round had started but had not ended yet. + note = self.note(self.list_league([-3], [ + ("Preliminary Finals", -13, -6), + ("Grand Final", -6, 1), + ], league_type=3, event_type=3, phase_label="Postseason")) + self.assertIn("season has finished", note) + + def test_an_offseason_round_does_not_count(self): + # College football's "Off Season" phase holds the All-Star week. + offseason = {"label": "Off Season", "startDate": self.iso(2), + "endDate": self.iso(6), + "entries": [{"label": "All-Star", "startDate": self.iso(2), + "endDate": self.iso(6)}]} + note = self.note(self.list_league( + [-3], [("CFP", -40, 1)], league_type=3, event_type=3, + phase_label="Postseason", extra_phases=[offseason])) + self.assertIn("season has finished", note) + + def test_pll_with_a_season_end_date_in_the_future_is_still_finished(self): + # A "day" whitelist whose last match day is past; the season's own + # endDate (1 January) is ignored. + note = self.note({ + "events": [{"date": self.iso(-9), "season": {"type": 2}}], + "leagues": [{ + "season": {"type": {"type": 2}, "startDate": self.iso(-271), + "endDate": self.iso(94)}, + "calendarType": "day", + "calendarIsWhitelist": True, + "calendarEndDate": self.iso(94), + "calendar": [self.iso(-30), self.iso(-22), self.iso(-9)], + }], + }) + self.assertIn("season has finished", note)