feat: add all Phase 1 plugins to registry - #5
Merged
Merged
Conversation
Added 6 new plugins to plugins.json: - weather: OpenWeatherMap integration - static-image: Static image display - text-display: Scrolling text display - of-the-day: Daily content displays - music: Spotify/YTM now playing - calendar: Google Calendar integration Total plugins in registry: 8 (including hello-world and clock-simple) All new plugins are on simple-plugins branch
ChuckBuilds
pushed a commit
that referenced
this pull request
Feb 14, 2026
- Wire up BaseballLogoManager for auto-download of missing logos via ESPN API, with fallback to inline logo loading when unavailable - Wire up BaseballRankingsManager to fetch real team rankings (AP Top 25 etc.) from ESPN standings API, cached for 1 hour - Update _draw_records in both manager.py and game_renderer.py to show "#rank" (e.g., "#5") when show_ranking is enabled, matching the football/basketball pattern - Add _get_team_display_text helper for consistent ranking/record display logic across switch and scroll modes - Pass rankings cache through scroll_display.py to GameRenderer via set_rankings_cache() for scroll mode support - Version bump to 1.3.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ChuckBuilds
added a commit
that referenced
this pull request
Feb 14, 2026
…ts (#27) * feat(baseball): add full scorebug rendering with baseball-specific elements Consolidate v2.5 baseball scorebug rendering into manager.py with dedicated display methods for live, recent, and upcoming games. Live games now show inning indicator (▲/▼), base diamonds, outs circles, balls-strikes count, and team:score layout. Standardize data extraction to flat dict format matching other sports plugins. Update scroll mode game_renderer.py with matching baseball elements. Remove unused scorebug_renderer.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(baseball): fix inning display, count fallback, and records config - Fix 'end of inning' rendering: don't increment period when 'end' is detected in status text, use distinct 'end'/'mid' inning_half values and render as E5/M5 markers instead of misleading ▲ arrows - Fix count fallback: check whether count dict is present/populated rather than testing for 0-0 values, which treated valid 0-0 counts as missing data - Fix game_renderer.py _draw_records: gate on show_records/show_ranking config flags from league_config to match manager.py behavior, so scroll mode respects user settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(baseball): integrate odds rendering, fix scroll logo paths - Wire up BaseballOddsManager in manager.py: import, initialize, and call fetch_odds/render_odds in all three display methods (live, recent, upcoming) when show_odds is enabled - Add _draw_dynamic_odds() to game_renderer.py for scroll mode cards, matching the pattern used by football/basketball plugins - Fix game_renderer.py _get_logo_path to read logo_dir from league_config instead of hardcoding paths, so custom logo directories (MiLB, NCAA) are respected in scroll mode - Move datetime/pytz imports to module level in game_renderer.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(baseball): integrate logo manager and rankings manager - Wire up BaseballLogoManager for auto-download of missing logos via ESPN API, with fallback to inline logo loading when unavailable - Wire up BaseballRankingsManager to fetch real team rankings (AP Top 25 etc.) from ESPN standings API, cached for 1 hour - Update _draw_records in both manager.py and game_renderer.py to show "#rank" (e.g., "#5") when show_ranking is enabled, matching the football/basketball pattern - Add _get_team_display_text helper for consistent ranking/record display logic across switch and scroll modes - Pass rankings cache through scroll_display.py to GameRenderer via set_rankings_cache() for scroll mode support - Version bump to 1.3.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(baseball): move odds fetch to update cycle, fix pick'em line and odds overlap Move blocking odds fetch from render path (_fetch_and_render_odds) to update() cycle so network I/O doesn't occur during display rendering. Fix pick'em line bug where home_spread == 0.0 was treated as missing data in both game_renderer.py and odds_manager.py. Fix odds y-position in game_renderer.py to render below the status row instead of at y=0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(baseball): thread-safe rankings, close logo file handles, Pillow compat Use atomic swap under _games_lock for _team_rankings_cache so display threads always see a consistent snapshot. Close Image.open file handles in logo fallback path and logo_manager.py by using context managers. Add RESAMPLE_FILTER compatibility shim for Pillow < 9.1 in both manager.py and logo_manager.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ChuckBuilds
added a commit
that referenced
this pull request
Feb 15, 2026
…5 plugins (#28) * feat(baseball): add full scorebug rendering with baseball-specific elements Consolidate v2.5 baseball scorebug rendering into manager.py with dedicated display methods for live, recent, and upcoming games. Live games now show inning indicator (▲/▼), base diamonds, outs circles, balls-strikes count, and team:score layout. Standardize data extraction to flat dict format matching other sports plugins. Update scroll mode game_renderer.py with matching baseball elements. Remove unused scorebug_renderer.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(baseball): fix inning display, count fallback, and records config - Fix 'end of inning' rendering: don't increment period when 'end' is detected in status text, use distinct 'end'/'mid' inning_half values and render as E5/M5 markers instead of misleading ▲ arrows - Fix count fallback: check whether count dict is present/populated rather than testing for 0-0 values, which treated valid 0-0 counts as missing data - Fix game_renderer.py _draw_records: gate on show_records/show_ranking config flags from league_config to match manager.py behavior, so scroll mode respects user settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(baseball): integrate odds rendering, fix scroll logo paths - Wire up BaseballOddsManager in manager.py: import, initialize, and call fetch_odds/render_odds in all three display methods (live, recent, upcoming) when show_odds is enabled - Add _draw_dynamic_odds() to game_renderer.py for scroll mode cards, matching the pattern used by football/basketball plugins - Fix game_renderer.py _get_logo_path to read logo_dir from league_config instead of hardcoding paths, so custom logo directories (MiLB, NCAA) are respected in scroll mode - Move datetime/pytz imports to module level in game_renderer.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(baseball): integrate logo manager and rankings manager - Wire up BaseballLogoManager for auto-download of missing logos via ESPN API, with fallback to inline logo loading when unavailable - Wire up BaseballRankingsManager to fetch real team rankings (AP Top 25 etc.) from ESPN standings API, cached for 1 hour - Update _draw_records in both manager.py and game_renderer.py to show "#rank" (e.g., "#5") when show_ranking is enabled, matching the football/basketball pattern - Add _get_team_display_text helper for consistent ranking/record display logic across switch and scroll modes - Pass rankings cache through scroll_display.py to GameRenderer via set_rankings_cache() for scroll mode support - Version bump to 1.3.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(baseball): move odds fetch to update cycle, fix pick'em line and odds overlap Move blocking odds fetch from render path (_fetch_and_render_odds) to update() cycle so network I/O doesn't occur during display rendering. Fix pick'em line bug where home_spread == 0.0 was treated as missing data in both game_renderer.py and odds_manager.py. Fix odds y-position in game_renderer.py to render below the status row instead of at y=0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(baseball): thread-safe rankings, close logo file handles, Pillow compat Use atomic swap under _games_lock for _team_rankings_cache so display threads always see a consistent snapshot. Close Image.open file handles in logo fallback path and logo_manager.py by using context managers. Add RESAMPLE_FILTER compatibility shim for Pillow < 9.1 in both manager.py and logo_manager.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: sports scoreboard parity — SportsCore for baseball, fixes across all 5 plugins Phase 1-3: Sync shared files and fix gaps across basketball, football, hockey, soccer - Add stale game detection to soccer (was the only SportsCore plugin missing it) - Add sticky manager logic to football and soccer (prevents mid-display league switching) - Add ScrollDisplayManager to soccer (was missing scroll mode support) - Expand soccer data_sources.py with standings/rankings/schedule fetching - Add mode_durations config to basketball and soccer - Fix cache_manager API calls across all 5 plugins (delete→clear_cache) - Fix hockey statistics .get() safety (prevents KeyError on missing stats) Phase 4: Refactor baseball to SportsCore architecture - Add SportsCore shared files: sports.py, data_sources.py, logo_downloader.py, base_odds_manager.py, dynamic_team_resolver.py - Create per-league managers: mlb_managers.py, milb_managers.py, ncaa_baseball_managers.py - Add baseball.py with baseball-specific extraction (inning, bases, outs, count) - Rewrite milb_managers.py to use MLB Stats API (ESPN MiLB endpoint returns 400) - Restructure config_schema.json from flat to nested per-league format - Rewrite manager.py with league registry, sticky manager, mode durations, dynamic duration, and full MiLB integration - Fix game_renderer.py records config path (was reading dead league_config from game dict) Version bumps: baseball 2.0.0, basketball 1.1.0, football 2.1.0, hockey 1.1.0, soccer 1.3.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review — cache TTL, finish detection, state leak, and safety fixes - Pass cache TTL (interval) to cache_manager.set() in all 5 base_odds_manager.py files so odds data expires correctly instead of living forever - Fix baseball base_odds_manager.py deprecated .delete()/.clear() → .clear_cache() - Fix premature finish detection in basketball/hockey/football sports.py: when clock is None or not a string, no longer default to "0:00" which falsely declares games over; instead skip clock-based check entirely - Fix baseball.py display_manager.draw state leak: save/restore with try/finally so BDF text rendering doesn't permanently overwrite the draw object - Guard data_sources.py against empty competitions[] and dates[] lists (IndexError) - Use LogoDownloader session with retry logic in logo_downloader.py instead of bare requests.get for logo downloads - Change MiLB Stats API URL from HTTP to HTTPS in milb_managers.py - Fix soccer manager.py get_cycle_duration: accumulate per-league (games * duration) instead of using a single overwritten game_duration variable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR nitpicks — remove duplicates, unused params, cleanup - Remove duplicate _get_games_from_manager and _get_rankings_cache definitions in hockey/manager.py that shadowed the original methods; pass mode_type in Vegas scroll calls that already had it in scope - Remove unused game_date variable in milb_managers.py - Use bool(offense.get()) for base runner detection in milb_managers.py instead of key-existence check that could misread null values - Simplify odds check in baseball.py to game.get("odds") - Remove unused Dict/Any/Tuple imports from logo_downloader.py - Remove unused game parameter from game_renderer._get_logo_path and _load_and_resize_logo, along with all 6 call sites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: minor code review fixes — len(None) crash, error-path rendering, bare excepts - Fix len(result.data.get('events')) crash in mlb/ncaa background callbacks - Fix error-path rendering in baseball.py (text drawn on discarded copy) - Remove unused font variable in baseball.py display_series_summary - Fix bare except clauses in logo_downloader.py - Fix placeholder User-Agent string in logo_downloader.py - Fix stray f-string prefixes in data_sources.py (baseball + soccer) - Fix comment mismatch (130% → 150%) in logo_manager.py - Change MiLB show_odds default to false in config_schema.json Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: odds sentinel leak, stale game detection, duration bounds, and cache TTL - Filter out {"no_odds": True} sentinel in error-path cache fallback across all 5 base_odds_manager.py files - Fix _detect_stale_games to operate on persisted self.live_games instead of freshly-built new_live_games (basketball, football, hockey) - Add minimum/maximum bounds (10-120) to recent/upcoming_game_duration in baseball config_schema.json (MLB, MiLB, NCAA sections) - Add 4-hour TTL to synchronous fallback cache in mlb/ncaa/milb_managers - Change MiLB situation field from None to empty dict to prevent downstream AttributeError Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: isinstance dispatch, ClassVar annotations, MiLB date-range optimization - Replace isinstance(self, XxxLiveManager) dispatch in base classes with proper _fetch_data overrides in each Live subclass (OCP compliance) - Add ClassVar annotations to shared mutable class-level state in mlb/ncaa/milb_managers.py to document intentional cross-instance sharing - Optimize MiLB API fetching: use startDate/endDate params for a single request instead of 15 sequential per-date requests - Add debug log when _extract_game_details skips malformed events Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: lock _detect_stale_games mutation, normalize EN DASH in comments - Wrap _detect_stale_games(self.live_games) in _games_lock to prevent races with display/reader threads (basketball, football, hockey) - Replace EN DASH with standard hyphen in no_odds comment across all 5 base_odds_manager.py files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: stale game detection in soccer and baseball — operate on self.live_games Soccer and baseball were calling _detect_stale_games(new_live_games) which is ineffective since new_live_games items have fresh last_seen timestamps. Aligned with basketball/football/hockey to check self.live_games under lock. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review nitpicks across all 5 scoreboard plugins - Fix soccer live data fetch nested inside show_ranking conditional - Remove unused cache_ttl from BaseOddsManager (all 5 plugins) - Use logger.exception() in odds error handlers for tracebacks - Add Optional type hints for None-defaulting parameters - Fix bare return → return None in baseball._extract_game_details - Add maxsplit=1 to count_summary.split for robustness - Remove redundant inning_half/inning_num assignments in baseball - Move BDF set_char_size to one-time lazy init to avoid per-frame overhead - Downgrade cache-hit log level from INFO to DEBUG in MLB/NCAA managers - Add season-year comment for MiLB March boundary logic - Reorder _fetch_data after __init__ in all *LiveManager classes - Prune game_update_timestamps for games no longer in live_games (all 5) - Add duplicate-submission guard for background fetch requests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ChuckBuilds
pushed a commit
that referenced
this pull request
Aug 27, 2026
None of this was written down anywhere a user would look. The settings existed only as schema descriptions in the web UI, plus code comments and commit messages -- and the behaviour is not guessable, which is exactly why it kept being misread. The section leads with the thing that trips people up: upcoming_games_to_show is not "how many cards you see", it is the size of a POOL that the panel cycles, keeping its place between visits. Making it bigger lengthens the lap, so any one game appears LESS often -- the opposite of what people reach for it to do. Then the three modes as a table, because which one you are in depends on two settings at once, and the useful one (favourites first, then others) is the combination that until now did nothing. Facts in it are measured rather than described: ~950 upcoming college games of which ~250 involve a ranked team; 18 distinct matchups over three hours of rotation while the pool stays at 6 cards. Every default quoted was checked against the schema. The AP_TOP_n warning is in both this section and the Dynamic Team Resolution section that introduces those patterns, because that is where someone meets them: expanding a group into the favourites list makes your own teams compete with it, and on a real schedule UGA's next game was favourite-game #5 and Auburn's #8, so neither appeared with a limit of 3. Shorter version in the other eight READMEs, without the college-specific detail, and noting that the quality and division filters are inert for leagues with no poll and no divisions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
ChuckBuilds
added a commit
that referenced
this pull request
Sep 1, 2026
…et seed
Verified the reported finding against the live API before changing anything,
and it holds: ESPN answers the men's and women's college hockey rankings
endpoint with two blocks and fronts NCAA TOURNAMENT SEEDINGS, ahead of the
USCHO poll.
hockey/mens-college-hockey [NCAA Tournament Seedings, USCHO Men's Poll]
hockey/womens-college-hockey [NCAA Tournament Seedings, USCHO Women's Poll]
_fetch_team_rankings took rankings_data[0], so a "#5" on a college hockey
panel was a 16-team bracket seed where a viewer expects a poll position. One
correction to the report along the way: the sibling lineages' fetch_standings
does NOT try /standings first the way football's used to -- it requests
/rankings directly -- so the rankings really were loading, and really were the
wrong block. Nothing was masking it.
Surveyed every college league rather than assuming hockey was alone:
lacrosse/mens-college-lacrosse [Inside Lacrosse Poll, Tournament Seedings]
lacrosse/womens-college-lacrosse[Inside Lacrosse Poll, Tournament Seedings]
basketball/mens-college-basketball [AP Top 25, Coaches Poll]
baseball/college-baseball [] -- no poll at all
So hockey is wrong today; lacrosse publishes seedings of its own and is one
reordering away; basketball and baseball are fine but share the code. The
chooser and its vocabulary are ported to all nine sports.py copies, which is
what the shared-sports-code rule asks for and what keeps the copies from
drifting apart again. The vocabulary is one list everywhere -- tournament
seedings and the divisions below the top one -- so football's copy and the
four DynamicTeamResolver copies gain the seedings exclusion too.
ESPN's own order is kept among genuine polls, so whichever poll it fronts
still drives the badge; that is how the CFP rankings take over from AP in
November without a code change. Verified live against all five college
leagues: hockey now picks the USCHO poll where it used to pick seedings, and
lacrosse, basketball and football keep the poll they already had.
scripts/test_dynamic_poll_choice.py is renamed to test_poll_choice.py and
covers both helpers: 78 checks over 4 resolver copies and 9 sports.py copies.
The sports.py half drives _fetch_team_rankings end to end rather than calling
_choose_poll directly -- checking the helper alone passed against a copy whose
call site still read rankings[0], the helper intact and simply unused, which
is precisely the state the guard exists to prevent.
lacrosse 1.19.1 -> 1.19.2, afl 1.17.1 -> 1.17.2, nrl 1.16.1 -> 1.16.2,
soccer 2.19.1 -> 2.19.2, ufc 1.7.1 -> 1.7.2. hockey, basketball, baseball and
football keep the versions this branch already bumped them to, with their
notes extended. The four with no college league say plainly that they are
hardening.
Harness green for all nine plugins (200 renders); collisions clean; all 12
repo guards pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ChuckBuilds
added a commit
that referenced
this pull request
Sep 1, 2026
… first block (#347) * feat(scoreboards)!: rank against the top division's poll, retire the broadcast tier The "ranked" quality filter now means ranked in the TOP division's poll. ESPN answers the college football rankings endpoint with four blocks -- AP Top 25, the AFCA Coaches Poll, the FCS Coaches Poll and the AFCA Division II Poll -- and the parser took whichever it listed first. That is AP today, so the table was FBS by luck rather than by choice: nothing in the payload promises the order, and ESPN changes it, adding the CFP rankings in November. With a lower-division poll leading, every top FCS side reads as ranked and a board asking for the week's best matchups is served South Dakota State at Northwestern -- the ranked side is FCS, the FBS side is unranked, and it is exactly the game the setting exists to keep off the panel. The poll is now chosen rather than trusted: ESPN's own order is kept among top-division polls, and the ones below FBS are stepped over. Verified against the live payload with the FCS poll moved to the front -- AP is still the table, and South Dakota State is still unranked. Teams are matched by id as well as abbreviation. The FBS and FCS schedules arrive in one scoreboard payload and an abbreviation is not unique across divisions -- ESPN has SDSU for San Diego State and SDST for South Dakota State today, with nothing promising it stays that way -- so two schools sharing one could promote each other into a ranked slot. The rank badge reads the same table, so it can no longer draw an FCS poll position on an FBS board. BREAKING CHANGE: the "broadcast" quality tier is retired. Measured against a real Week 1 and Week 2 college slate it passed 174 of 175 games -- ESPN lists a broadcaster for nearly everything now, ESPN+ included -- so it read as a quality bar in the dropdown and behaved as "any". Boards holding it are read as "ranked" and say so once in the log; changing the setting clears the schema warning the core raises for a value no longer in the enum. An unusable value there now falls back to "ranked" with a warning too, instead of falling through every branch and silently meaning "any". odds-ticker shares this rankings code and also covers college football, so the poll-selection guard is ported there in the same change. football-scoreboard 2.29.3 -> 3.0.0, odds-ticker 1.3.2 -> 1.3.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(football-scoreboard): give the recent-path probe its show_odds attribute test_favorite_live_boost.py has failed since 2.29.3, which made SportsRecent.update() fetch odds for the finals it selects. The probe there is hand-built with __new__, so it has none of SportsCore.__init__'s attributes, and the new `if self.show_odds:` raises AttributeError inside update()'s own try/except. The exception is swallowed, games_list is left empty, and the check reads as "the exclude filter leaked" when the log actually shows the selection doing its job -- SF filtered out, SEA kept, "No favorites configured: showing 1 total recent games" -- immediately before the raise. A fixture gap, not a product bug: show_odds is always set by SportsCore.__init__ on a real manager. Setting it on the probe turns the file green (15/15) with the exclude assertion passing on its own merits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scoreboards): resolve AP_TOP_n from the top division's poll too Review finding on #347: sports.py's _choose_poll fixed the ranked-game filter, but DynamicTeamResolver._fetch_rankings still took data['rankings'][0] -- "Use first ranking (usually AP)". Verified against the current code; the finding's line reference pointed at sports.py, where those lines are the new _choose_poll, but the substance holds and the second site is real. The consequence there is worse than for the filter. ESPN answers the college football rankings endpoint with four blocks -- AP Top 25, the AFCA Coaches Poll, the FCS Coaches Poll and the AFCA Division II Poll -- and with a lower-division poll leading, AP_TOP_25 resolves to 25 FCS schools and installs them as the user's FAVOURITE teams. Favourites are never filtered by quality or division, so every one of those games reaches the panel: the user asks for the AP Top 25 and gets the FCS Coaches Poll. Reproduced end to end -- against an FCS-fronted payload the unpatched resolver returns ['MTST'] where the fixed one returns ['OSU']. The resolver is copied into four plugins and all four map that endpoint, so all four are fixed together. AP_TOP_n is reachable through it in basketball-scoreboard, hockey-scoreboard and football-scoreboard; baseball-scoreboard's patterns resolve from the college baseball poll instead, so its copy is hardening with no behaviour change, kept in step rather than left to diverge. It cannot import the sports.py helper -- sports.py imports this module, and the core loads both as bare top-level names -- so the exclusion vocabulary is duplicated with a comment pointing at its twin. scripts/test_dynamic_poll_choice.py holds every copy to it, checking behaviour rather than reading the source, plus a source check so no copy reintroduces the index. Confirmed it fails on the unpatched code. Also unblocks CI: scripts/check_selection_settings.py hardcoded the other_games_min_quality enum as {any, broadcast, ranked}. Only football-scoreboard's sports.py reads that key and the guard scopes itself by exactly that, so narrowing the set to {any, ranked} touches nothing else. baseball-scoreboard 1.35.1 -> 1.35.2, basketball-scoreboard 1.24.1 -> 1.24.2, hockey-scoreboard 1.20.1 -> 1.20.2. football-scoreboard stays at the unreleased 3.0.0, with its notes amended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scoreboards): the rank badge reads a poll, not a tournament bracket seed Verified the reported finding against the live API before changing anything, and it holds: ESPN answers the men's and women's college hockey rankings endpoint with two blocks and fronts NCAA TOURNAMENT SEEDINGS, ahead of the USCHO poll. hockey/mens-college-hockey [NCAA Tournament Seedings, USCHO Men's Poll] hockey/womens-college-hockey [NCAA Tournament Seedings, USCHO Women's Poll] _fetch_team_rankings took rankings_data[0], so a "#5" on a college hockey panel was a 16-team bracket seed where a viewer expects a poll position. One correction to the report along the way: the sibling lineages' fetch_standings does NOT try /standings first the way football's used to -- it requests /rankings directly -- so the rankings really were loading, and really were the wrong block. Nothing was masking it. Surveyed every college league rather than assuming hockey was alone: lacrosse/mens-college-lacrosse [Inside Lacrosse Poll, Tournament Seedings] lacrosse/womens-college-lacrosse[Inside Lacrosse Poll, Tournament Seedings] basketball/mens-college-basketball [AP Top 25, Coaches Poll] baseball/college-baseball [] -- no poll at all So hockey is wrong today; lacrosse publishes seedings of its own and is one reordering away; basketball and baseball are fine but share the code. The chooser and its vocabulary are ported to all nine sports.py copies, which is what the shared-sports-code rule asks for and what keeps the copies from drifting apart again. The vocabulary is one list everywhere -- tournament seedings and the divisions below the top one -- so football's copy and the four DynamicTeamResolver copies gain the seedings exclusion too. ESPN's own order is kept among genuine polls, so whichever poll it fronts still drives the badge; that is how the CFP rankings take over from AP in November without a code change. Verified live against all five college leagues: hockey now picks the USCHO poll where it used to pick seedings, and lacrosse, basketball and football keep the poll they already had. scripts/test_dynamic_poll_choice.py is renamed to test_poll_choice.py and covers both helpers: 78 checks over 4 resolver copies and 9 sports.py copies. The sports.py half drives _fetch_team_rankings end to end rather than calling _choose_poll directly -- checking the helper alone passed against a copy whose call site still read rankings[0], the helper intact and simply unused, which is precisely the state the guard exists to prevent. lacrosse 1.19.1 -> 1.19.2, afl 1.17.1 -> 1.17.2, nrl 1.16.1 -> 1.16.2, soccer 2.19.1 -> 2.19.2, ufc 1.7.1 -> 1.7.2. hockey, basketball, baseball and football keep the versions this branch already bumped them to, with their notes extended. The four with no college league say plainly that they are hardening. Harness green for all nine plugins (200 renders); collisions clean; all 12 repo guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scoreboards): size a cycle from the games that are actually on the board get_cycle_duration asked the manager for recent_games/upcoming_games: elif mode_type == 'upcoming': games = getattr(manager, 'upcoming_games', []) SportsUpcoming.__init__ declared `self.upcoming_games = []` -- "Store all fetched upcoming games initially" -- and never assigned to it again; the selected games go to games_list. SportsRecent.recent_games is the same. So total_games was always 0 and every recent or upcoming cycle fell through to the "no games yet" default of three games' worth instead of scaling with the number of cards. Live mode reads live_games, which IS populated, which is part of why this went unnoticed. Verified against the current code rather than taken on trust, and the survey narrowed it: only football and baseball had the bug. basketball, hockey and lacrosse already reach for games_list first inside this function, and afl and nrl already call _get_games_from_manager -- the helper the scroll path uses, which resolves it correctly. Those two adopt the helper rather than growing a fourth shape for the same decision. soccer never had it; ufc has no get_cycle_duration. The two attributes are removed from all nine lineages. Nothing filled them and nothing read them, and their presence is exactly what made a duration calculation reading them look correct. scripts/test_cycle_duration_counts_real_games.py holds all 13 copies of the function to it, and holds the attributes gone. Confirmed it fails on the unpatched code, on all three counts. One thing worth knowing about the plugin's own test_dynamic_duration.py: its assertions sit behind `if hasattr(plugin, "nfl_recent") and plugin.nfl_recent`, and when the managers fail to build -- which is what happens under pytest without the core importable -- the whole block is skipped and the file passes having verified nothing. It reported green against the bug throughout. Its three writes to the dead attribute now target games_list and it asserts that two different game counts give two different durations, so it is correct where it does run, but the guard above is the one that actually runs everywhere. Every touched plugin was already bumped earlier on this branch; notes extended rather than re-bumped. Harness green for all nine (192 renders), all 13 repo guards pass, collisions clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scoreboards): port the favourite-check advisory fix to the sibling copies CI on this PR went red on hockey-scoreboard/test_favorite_check.py, which asserts every *_favorite_check.py copy stays byte-identical. The drift is not from this branch: #345 fixed the "nothing on until" advisory in football_favorite_check.py and did not port it, so origin/main fails this same test today -- confirmed by running it against origin/main in a clean worktree. The merge simply brought main's red into this PR. The six sibling copies now carry the same fix. It was a literal byte copy: the six were identical to each other and football differed by exactly #345's change, which is what the guard defines as correct. The fix itself, for the record: the advisory pooled event dates with the league calendar's week and phase startDates and took the earliest. Calendar weeks open days before their first game -- an NFL week 1 entry starts the weekend before a Thursday opener -- so a boundary was reported as a game date. Events win now; the calendar only speaks for a payload with no events at all. All six were already bumped earlier on this branch; notes extended rather than re-bumped. hockey's parity test passes 30/30, harness green for all six, and each plugin's suite shows no failure it did not already have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ChuckBuilds
added a commit
that referenced
this pull request
Sep 2, 2026
…neages (#353) * feat(sports): only fill the other slots with games worth watching Selection was purely chronological, so rotating harder just served more filler. On a real board's college schedule, 923 non-favourite upcoming games: 235 involve a nationally ranked team and the rest are matchups the viewer has never heard of. Two settings now decide what fills the slots left over after favourites, and neither ever touches favourites themselves -- follow a smaller-division school and its games always show. other_games_min_quality (default "ranked") uses the rankings table the plugin already fetches for the rank badge, so it costs no extra requests. That gating had to move: rankings were only fetched when show_ranking was on, which left the filter with an empty table and would have emptied the board. other_games_divisions (default ["fbs"]) needs ESPN's own group rosters -- two requests a day, cached. conferenceId cannot do this job: cross-division games put an FBS conference on an FCS slate, so the id sets overlap and Merrimack at Delaware classifies as FBS. The group rosters are disjoint (148 FBS ids, 130 FCS). Every participant must be in a checked division, so leaving FCS unchecked also removes a ranked side hosting an FCS school -- which is the actual complaint. Every check fails OPEN. A rankings table that did not load, or divisions that did not resolve, allows the game: a board showing filler is poor, a board showing nothing is broken. Three real defects found while testing this, all in the already-pushed commits of this PR: - SportsRecent is a SIBLING of SportsUpcoming, not a subclass, so its call to _favorites_first hit a method it did not have. AttributeError, swallowed by update()'s own try/except, recent games silently blank. The shared helpers now live on SportsCore and a test drives the real SportsRecent class rather than only Upcoming, which is what hid it. - nrl matches favourites by ESPN team id on purpose -- its abbreviations are not unique, "NEW" is both Newcastle Knights and New Zealand Warriors -- and the ported abbreviation-based matcher shadowed that on the upcoming path, where it would favourite the wrong club. Removed; nrl keeps its own. - The custom-league editor is an array-table, and array-table.js stringifies a list into "a,b" before submitting, so an array-typed property inside a row can never validate. The checkbox group is out of custom_leagues; the enum string stays. Class-level defaults for everything the selection path reads, because that read happens inside update()'s try/except: a missing attribute does not raise anywhere visible, it just blanks the board. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(sports): make the new settings actually take effect Two defects found while re-checking the logic with the season a week out. Both are the same shape: nothing raises, nothing logs, the feature is just quietly not there. 1. None of the five new settings reached the code. Managers do not read the plugin config -- _adapt_config_for_manager translates it, and that translation is an explicit whitelist. Every one of the new keys was declared in the schema, rendered in the web UI, read by sports.py, and dropped in between. A user could set them, save, and nothing would change; the code kept its own defaults. All nine plugins, and the lineages disagree about where the values live: game_limits, filtering, or the league root, with hockey and lacrosse going through resolve_value instead. Each now reads from the same place its own schema declares them. test_settings_reach_the_manager.py guards it, using values that are NOT the defaults -- a fixture built from defaults passes against a translation that drops the key entirely. 2. Making "ranked" the default quality made every league fetch rankings, and only college leagues have them: NFL's endpoint 404s. _fetch_team_rankings only short-circuits on a NON-empty cache, so a failed fetch leaves it empty and the next update tries again -- roughly 2,900 dead requests a day per non-college league at a 30s interval. Gated on the league actually having a poll. Verified against the live API rather than assumed: the division lookup fetches 148 FBS and 130 FCS team ids, caches them, serves a second instance without touching the network, and is skipped entirely for NFL. college-football's rankings endpoint returns 25; nfl's returns 404. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(sports): fetch each ranking poll once, not once per group AP_TOP_5, AP_TOP_10 and AP_TOP_25 all resolve from the same poll and differ only in how far down it they slice -- and the value cached was the whole list. The key, though, named the pattern, so configuring two groups fetched the identical payload twice, stored it twice and expired it twice for no difference in the result. Seen on a real board: dynamic_teams_ncaa_fb_AP_TOP_10.json and dynamic_teams_ncaa_fb_AP_TOP_25.json side by side, both holding the same 25 teams. Keyed by sport instead. Five plugins carry a resolver with patterns; two key spellings between them. afl, nrl and soccer ship a stub with no patterns at all, so there is nothing to key. The test asserts one fetch PER SPORT rather than one overall: hockey and lacrosse declare groups across several sports, and those really are separate polls. It also skips the shared-cache assertion for lineages that pair a class-level dict with a per-instance freshness stamp, where a new instance refetches by construction -- pre-existing, and not what this change governs. Reverting the key in any of the five fails three checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(sports): fetch college rankings from the endpoint that has them Driving the real NCAAFBUpcomingManager end to end -- which nothing had done -- showed the selection working and "rankings loaded: 0". The quality filter was failing open on every board. fetch_standings tried /standings first and fell back to /rankings only on a 404. College football answers /standings with HTTP 200 and no "rankings" key, so the fallback never fired. Verified against the live API today: football/college-football/standings 200, no "rankings" key football/college-football/rankings 200, 3 ranking blocks football/nfl/standings 200 football/nfl/rankings 404 Nothing ever failed. _fetch_team_rankings parsed a body with no rankings in it and cached an empty table, so the AP rank badge never appeared however show_ranking was set -- that part predates this PR -- and the new "ranked" filter passed every game, because an empty table fails open. The endpoint is now chosen by league rather than discovered by error code, and a 200 without the key counts as a miss. After the fix the same end-to-end run loads 25 rankings and fills the other slots with SJSU@USC (#14), UTEP@OU (#10) and MIA@STAN (#7) instead of the next three unranked games. Also verified end to end, both directions: NFL recent returns 2 TB games plus 2 others, newest first, and fetches 0 rankings -- no poll exists, and none is requested. Note on the baseball copy: the first attempt replaced from fetch_standings to the next top-level class, which in that file swallowed fetch_game_summary, fetch_player_details and _parse_player_details. Its own test_player_card caught it. Redone bounded to the method, and targeted at ESPNDataSource specifically -- the abstract declaration above it has the same signature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * test(football): drive the real manager classes, not just the base ones The SportsRecent defect survived every test because they all drove SportsUpcoming directly. This drives what the plugin actually instantiates -- live, recent and upcoming, for both leagues -- and asserts each carries every selection helper and setting. Favourite detection is checked in BOTH directions. Asserting only that a favourite returns True passes against a matcher that returns True for everything, which would sweep the whole league into the favourites bucket and quietly empty the other-games slots. Reverting the helpers to SportsUpcoming reproduces the original failure verbatim: 'NFLRecentManager' object has no attribute '_is_favorite_game'. Verified alongside, by driving the real managers end to end against live ESPN data rather than fixtures: - NCAA upcoming: 3 favourites (UGA, AUB) plus 3 ranked others -- SJSU@USC (#14), UTEP@OU (#10), MIA@STAN (#7) - NFL recent: 2 TB games plus 2 others, newest first, 0 rankings requested - both live managers update cleanly and resolve favourites - the config the web UI writes from schema defaults validates, a user-edited one validates, and a string where an array belongs is rejected Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * docs(sports): explain how favourite selection actually works None of this was written down anywhere a user would look. The settings existed only as schema descriptions in the web UI, plus code comments and commit messages -- and the behaviour is not guessable, which is exactly why it kept being misread. The section leads with the thing that trips people up: upcoming_games_to_show is not "how many cards you see", it is the size of a POOL that the panel cycles, keeping its place between visits. Making it bigger lengthens the lap, so any one game appears LESS often -- the opposite of what people reach for it to do. Then the three modes as a table, because which one you are in depends on two settings at once, and the useful one (favourites first, then others) is the combination that until now did nothing. Facts in it are measured rather than described: ~950 upcoming college games of which ~250 involve a ranked team; 18 distinct matchups over three hours of rotation while the pool stays at 6 cards. Every default quoted was checked against the schema. The AP_TOP_n warning is in both this section and the Dynamic Team Resolution section that introduces those patterns, because that is where someone meets them: expanding a group into the favourites list makes your own teams compete with it, and on a real schedule UGA's next game was favourite-game #5 and Auburn's #8, so neither appeared with a limit of 3. Shorter version in the other eight READMEs, without the college-specific detail, and noting that the quality and division filters are inert for leagues with no poll and no divisions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW * fix(sports): expire the division lookup instead of holding it forever The in-memory copy had no clock. _load_division_team_ids returned early on any non-None value, so the first result a process produced was the only one it ever used, and a board that happened to be offline for that first lookup ran with division filtering disabled until someone restarted the service -- on a display that stays up for weeks, indefinitely. A roster that changed between seasons was never picked up either. The copy now expires like the stored one: a day for a resolved lookup, ten minutes for one that came back empty, so a blip costs minutes rather than a day without retrying per frame. Also lower-cases the league before the "college" test -- the guard that decides whether to make the two requests at all was case-sensitive on a value the config supplies. The probe sets the freshness stamp alongside the pre-loaded ids: a populated cache with a zero stamp now reads as stale and goes back to the network, which is not what a test pre-loading divisions means. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * chore(sports): publish the follow-up work as new plugin versions Nine minor bumps, one per sports plugin. The 1.32.0-generation entry that 1.32.0 already shipped under is restored to the text it was released with -- it had been edited in place while this work was still on the same branch, and it now described settings that version does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(sports): act on the review -- divisions, empty favourites, dead tests Six of CodeRabbit's thirteen comments were real. Three are behaviour, three are the tests and docs that let the behaviour hide. 1. FBS/FCS is a college FOOTBALL taxonomy, and the lookup ran for any league whose name contains "college". Checked against the live API, groups 80 and 81 exist for that one league: football/college-football 200, 148 FBS + 130 FCS team ids baseball/college-baseball 500 lacrosse/mens-college-lacrosse 500 basketball/mens-college-basketball 200, 0 items basketball/womens-college-basketball 200, 0 items hockey/mens-college-hockey 200, 0 items An empty roster fails open, so the setting filtered nothing on those leagues; it only cost two requests a day and two warnings in the log. The group ids are now keyed by league, and the schema and READMEs say plainly that the division filter is college football alone rather than implying every college league has divisions to pick from. 2. With no favourite teams configured, selection took the next N games chronologically and never called _passes_other_filters. Every game in that branch is a non-favourite game, so both settings were inert for exactly the boards that have nothing else narrowing the list -- ask for ranked games only, get the next three kickoffs. Both branches now go through _filtered_or_all, which fails open as a whole: a filter matching nothing would blank the mode, and there is no favourite left to carry it. 3. afl and nrl declare these keys twice, at the config root and inside game_limits, and the web UI renders both. afl's translation read the root, nrl's read game_limits, so each plugin had a set of controls that accepted input and dropped it. Both now read either, game_limits first, matching how nrl already resolved the two older limits. 4. The division-filter assertion was vacuous in all nine test copies. The fixture put home ids in the division sets and away ids nowhere, so every away side classified as "other", the filter dropped the whole slate, and all() over the empty result passed -- it would have passed just as well against a filter that rejected everything. The sets now cover both sides, one game straddles deliberately (and is the FIRST game, or selection never reaches it), and the count is asserted. Ablating _game_divisions to the home side only now fails two checks; before, it failed none. 5. test_settings_reach_the_manager now asserts that every location a plugin's own schema offers actually reaches the manager -- root, game_limits or filtering, whichever that schema declares -- rather than one fixture that fills in all three and passes whichever the adapter happens to read. Reverting the afl fallback fails five of its checks. 6. E731: the two assigned lambdas in the ranking-fetch tests are functions. Skipped, with reasons: - "Read broadcast data from `broadcasts`, not `broadcast`." The scoreboard payload carries both, and `broadcast` is the string this code wants: college-football "NBC", nfl "NFL Net", college-baseball "ESPN", mens-college-lacrosse "ESPN", ufc "Paramount+". Where it is empty (soccer/eng.1, nhl) `broadcasts` is an empty list too, so reading the other key changes nothing. - "Namespace the persistent cache keys by plugin." They are already keyed by sport and by league, and the copies of the resolver store the same shape, so a shared entry is the same data fetched once instead of twice. - "Load deferred plugin modules under unique module names." Each test script runs in its own process and puts only its own plugin directory on sys.path, so there is no other plugin's module to collide with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(sports): make the filters fail open as a set, not just per check Three gaps found reading the selection logic back, all the same shape: a filter doing exactly what it was asked leaves the board with less than the user expected, and nothing says why. 1. `_passes_other_filters` fails open per check -- a ranking table that could not be fetched allows every game -- but the SET of filters had no such guard on the favourites path. Favourites idle inside the schedule window plus a quality bar nothing clears meant an empty list, which is a blank mode rather than a short one. `_filtered_or_all` already made that whole-list fallback for a board with no favourites; the favourites path now makes the same one. `other_..._games_to_show` of 0 is an explicit "favourites only" and is still honoured, blank or not. 2. "broadcast" was the one check that could not fail open, because the scoreboard payload always carries the key -- it is simply empty in leagues ESPN publishes no listings for. Measured today: college-football "NBC" nfl "NFL Net" college-baseball "ESPN" mens-college-lacrosse "ESPN" mma/ufc "Paramount+" soccer/eng.1 "" hockey/nhl "" So picking it on a hockey or soccer board removed every non-favourite game. Coverage is now read off the slate rather than a hardcoded league list: no game carrying a broadcaster means the data is absent, not that nothing is on television, and the check allows everything. 3. The ranking table is keyed by the abbreviation the RANKINGS endpoint returns and matched against the SCOREBOARD's. Nothing guarantees the two agree, and if they stop agreeing the filter silently removes every non-favourite game -- the same silence that let "rankings loaded: 0" run on real boards until someone went looking. A loaded poll matching no game on the schedule now warns, throttled to once an hour, and names the abbreviations it holds so the mismatch is visible rather than inferred. Ablating any one of the three fails its own check and no others. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * feat(sports): one side in a checked division is enough, and guard the schema The division filter required EVERY participant to be in a checked division, so the default ["fbs"] read as "FBS versus FBS only". Measured against the real Week 2 slate, that silently removed five of the twenty ranked matchups -- Missouri, Utah, Tennessee, Texas Tech and BYU, each hosting an FCS school. Those are games about a team the viewer checked the box for. One side is now enough. FBS vs FCS is in; FCS vs FCS is still out unless `fcs` is checked, which is what the setting is actually for. On the same slate, with the same rotation, coverage of ranked matchups goes 15/20 -> 20/20, and the longest gap between two showings of one game moves 40 -> 69 minutes because the pool is five games larger. Favourites remain exempt from every filter, so a favourite's own FCS tune-up game was showing before this change and still is. The quality filter keeps this from becoming a flood: with the default "ranked", an unranked FBS side hosting an FCS school is rejected on quality anyway, so what the looser rule admits is specifically the ranked matchup. Also, from an audit of all 31 selection blocks across the nine schemas: - The five settings are declared in every block, each with a default, matching types, and identical ranges (0-20 counts, 0-86400 seconds, the two enums). The `other_*` counts default to their own block's limit, so an upgrade keeps the games the board was already showing. - scripts/check_selection_settings.py now enforces that, structurally: it finds every properties-dict that declares a game limit, so a league added later is covered without editing the checker. Its self-test asserts the repo passes AND that five separate kinds of gap are caught, because a guard that cannot fail is indistinguishable from one that passes. - soccer's custom_leagues block is the one place a setting is legitimately absent. array-table.js coerceValue() has no array branch: it submits "fbs" where the schema wants ["fbs"], and jsonschema then rejects the entire save, not just that field. Adding it there broke three cases in test_custom_league_config.py, which is how the constraint was found. The checker knows about row editors and does not demand arrays inside one. - The reads are hardened. These land in update()'s own try/except, so a string where an integer belongs surfaced as a mode that rendered nothing rather than as an error. Counts and the interval clamp to their declared range, other_games_min_quality is case-normalised, and a bare "fbs" becomes one division rather than list("fbs") == ['f','b','s'] -- three names matching nothing, which rejected every non-favourite game. Ablating the division rule back fails two checks; ablating any of the schema guarantees fails the guard's self-test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(sports): rotate on the display path, and make three settings reachable Two findings from auditing the configuration surface, and they compound: the rotation interval was a setting that could not take effect, gated behind an interval that could not be changed. 1. `_other_games_window` only ever ran from update(), which returns early until `upcoming_update_interval` has passed -- an hour. So `other_rotation_interval_seconds: 240` did not produce fifteen slices an hour; it produced one jump of fifteen windows, once an hour. On a real board that reads as the same two matchups for six hours, which is exactly what ledpi's journal showed: 89 visits to ncaa_fb_upcoming, UNC@TCU and SJSU@USC every single time. Which games exist, and which are worth a slot, is a fetch concern. WHICH of them is on screen is a display concern. The composition is now split out of _favorites_first, and the two display paths re-cut the slice when the interval passes -- one list slice and a sort of a few games, no network. Same lesson `_advance_live_game_if_due` already carries a comment about, for the same reason: gating a display decision on the fetch quantises it to the refresh rate. The card on screen keeps its place if it survived the cut, so rotating changes what comes next rather than interrupting what someone is reading. 2. Five settings sports.py reads were unreachable: no schema declaration, no translation, permanently at their built-in defaults. Three are worth exposing and now are -- recent_update_interval, upcoming_update_interval and stale_game_timeout. The odds intervals are left internal: odds are already fetched per selected game, so the knob would be a second lever on the same behaviour. The shape of the gap differed per plugin, which is why one audit found all of it: afl, nrl and soccer DECLARED the two intervals and dropped them in translation -- controls the form rendered and the board ignored, the same bug this PR opened with. baseball, basketball, football and ufc had neither. hockey and lacrosse were already complete, under `update_intervals.recent` and `.upcoming`; only the staleness guard was missing there. `update_interval_seconds` stays undeclared deliberately: all three managers overwrite it with their own per-mode interval, so a control for it would do nothing in the place a user would expect it to. The propagation test now covers all eight settings rather than the five selection ones, and asserts each still arrives from every location its own schema offers. Removing any single adapter entry fails two of its checks. Also updates the empty-mode stand-in in three plugins, which builds a bare object carrying only what display() touches -- it binds the two real rotation methods rather than stubbing them, so a regression that made them raise is caught rather than hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * feat(football): default college football to five games, no favorite team `favorite_teams` was already empty by default; the counts were 1. With no favorites configured every card is a non-favorite card, so a fresh college install showed exactly one game and repeated it until the schedule moved on -- which is the state ledpi was in this morning, two games shown 89 times each over six hours. Five in each of the four counts. The `other_*` pair mirrors its own limit, as in every other block, so adding a favorite team later adds to what is there rather than replacing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * feat(sports): order the other-games pool by the matchup, not the clock The quality filter declares the poll to be the thing worth showing, and then selection ignored the number entirely. #1 against #2 and #25 against an unranked side were interchangeable: both passed the gate, and whichever kicked off sooner took the slot. Rank was fetched, cached, and used for nothing but the badge painted on the card -- the only sort touching it in the whole file was `sorted(rankings)[:8]`, for a log line. The non-favourite pool is now ordered by the better of the two sides' poll positions before the window slices it, with kickoff as the tie-break. The rotation still walks the entire pool, so coverage and the measured gaps are unchanged; it walks DOWN the ladder instead of along the clock. What changes is which games lead: the first window after a restart or an update holds the best game available rather than the earliest, and a board is far more often freshly started than three hours into a lap. Favourites keep kickoff order. Ordering your own teams by rank would put a week-8 fixture ahead of Saturday's, and for your own team the next game is the point -- a test pins that. A league with no poll keeps the chronological order it had, because there is nothing to sort on: `_by_importance` returns the list untouched when the rankings table is empty, which is also what happens on a failed fetch. Reverting the ordering fails the check that the first window holds the best game; the fixture puts the top-ranked matchup last chronologically so the two orderings cannot agree by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(sports): one game per team in the ordered pool Shipping the rank ordering to a real board showed what the chronological order had been hiding: the upcoming pool is not a week of fixtures. For college football it is the whole season -- ledpi logged "Found 947 total upcoming games in data" -- so ordering by rank alone stacked all twelve of the #1 team's games above the #2 team's first one. The board went straight to KENT@OSU, ILL@OSU, then OSU@IOWA, MD@OSU: Ohio State's season, in order, before any other matchup. The pool now keeps one game per team, the soonest, and orders those by rank. It reads as "what each team has next, best matchup first", which is what an upcoming board means, and it is inherently near-term without a horizon setting to tune: a team's next game is by definition its closest one. Deduping happens on a soonest-first pass rather than on the rank-ordered one. Taking the first entry per team out of rank order would keep whichever game sorted first by rank, and for a game between two ranked sides that is not necessarily the one being played next. The ablation reproduces the board's symptom exactly -- top0, top1, top2, top3 -- so the test fails for the reason it was written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(sports): share the favourite slots between your teams The other-games pool refuses to spend its slots on one team; favourites were still doing exactly that. `favorites[:limit]` takes the soonest N, and the upcoming list is a season, so a team that plays either side of another's bye takes both slots. Walked across a real 901-game season with UGA and AUB at a limit of 2: nine days showed Auburn twice and Georgia not at all. Round-robin instead -- each favourite team's next game before any team's second. Depth survives where there is room: one favourite with three slots still gets its next three games, because a team's second game only comes up once every team has had a first. A game between two favourites is picked once and counts for both. Which side of a game belongs to which favourite turned out to be a per-lineage question. NRL matches on ESPN team ids because its abbreviations are not unique -- "NEW" is both Newcastle and New Zealand -- while the other eight match on abbreviation. The first version assumed abbreviations and silently grouped nothing there: every queue empty, every favourite slot empty, and the nine plugins would have disagreed about what the setting does. It now asks for the lineage's own matcher. The fixture gives each team both spellings, as the existing one does, so a single test covers both styles -- and the ablation fails on the id-matching lineage too, which is what proves that path is really exercised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * test(harness): name the check that failed, not the last line logged Nine plugins failed in CI with the same reason -- "ignoring unusable other_upcoming_games_to_show='not a number', using 3" -- which is a log line from a test that PASSES, emitted on stderr near the end of the script. The runner reported the last line of stdout+stderr, and for any script that warns on stderr that is the warning, whether the run passed or failed. The reason was therefore identical for every failure and named nothing. It now reports the checks that actually failed, up to three, and falls back to the exit code plus the last few lines when a script died without naming one -- a traceback, or an exit from somewhere unexpected. That case has to stay visible: it is the one where there is no check name to report and the tail is all the evidence there is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(sports): a fresh boot must not swallow the first coverage warning CI failed all nine plugins on "the mismatch is reported" and no machine here could reproduce it. The reason is uptime: monotonic() counts from an arbitrary origin, a few hundred seconds on a runner that just booted, and the throttle compared it against a stamp of 0. So "never logged" read as "logged at the epoch", and the first warning was suppressed for the first hour of uptime -- precisely when a misconfigured board is being watched. This machine has days of uptime, so monotonic() dwarfs the hour and it always passed. Zero now means never logged, as it already does for the rotation clock a few methods up. The test drives it at 120 seconds of uptime rather than trusting the host's, so the case is pinned on any machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * feat(hockey): sunset the bundled scroll fallback, floor at 3.2.0 (#346) B6, piloted on one plugin. B5 moved this scoreboard onto the core's src.common.sports_scroll behind a guarded import, keeping the frozen pre-adoption implementation as scroll_display_legacy.py so it still ran on a core that predated the module. That copy is now deleted and the import is plain. The guard goes with the copy, deliberately. Keeping try/except with nothing behind it would name the missing scroll_display_legacy rather than the core module that is actually absent -- and that name is the whole user-visible contract here, because PluginManager catches the ModuleNotFoundError and parks the plugin in ERROR with one log line. Get it wrong and the user is told the wrong thing about why their scoreboard vanished. Hockey first, alone, because B5 shipped four of eight plugins with scroll mode broken at once and every gate was green at the time. It is also the only adoption besides baseball exercised on real hardware. The floor rises to 3.2.0 -- the release that ships the module, not a later one: a floor describes what the plugin needs. compatible_versions moves to >=3.2.0 with it, since compatibility.check evaluates the range before the floor and leaving >=2.0.0 beside a 3.2.0 floor would be self-contradictory even though it changes no verdict. Behaviour is unchanged. The frozen copy and the live path were method-for-method identical for every content method, all 16 safety-harness renders pass across eight panel sizes, and the class-level separator-icon constants -- the exact thing B5 lost -- are byte-identical after the de-indent. 703 lines removed. test_core_fallback.py -> test_core_scroll.py, rewritten rather than deleted: its machinery has already caught two shipped bugs, and both were load-time bugs the harness cannot see because the core base catches exceptions out of prepare_scroll_content. It now asserts the sunset instead of the fallback -- the import is top-level and unguarded, no copy exists or is imported, the base is the core class by identity, an old core fails naming exactly src.common.sports_scroll, and the manifest floors at 3.2.0 or above. That last one is load-bearing: nothing else in either repo checks the floor's VALUE. The harness runs against core main, which has the module whatever the manifest says; check_manifest_version_fields checks the field's spelling, not its number; the registry carries no floor at all. A sunset shipping with a 2.0.0 floor would be green everywhere while the store handed it to a 3.1.0 core. The cross-path attribute diff that caught afl's unset _game_renderer has no second path left to diff against, so it is replaced by a static self-attribute audit: attributes a method reads that nothing in the class assigns and the built object does not carry. Same defect class, one path. check_scroll_adoption.py gains sunset_violations and a SUNSET_PLUGINS set. offending_classes is left byte-identical so its eleven pinned cases keep meaning what they mean. The existing check asks whether the fallback was INLINED and structurally cannot ask whether it still EXISTS -- it opens scroll_display.py and nothing else -- so a resurrected file or a returned guard would both pass. The set is listed rather than inferred: a plugin that never adopted legitimately has neither guard nor copy, so adding an id is the deliberate act of stating the sunset holds, in the same PR as the deletion. The self-test's most important new case is the negative one: an unrelated try/except must not read as the guard returning. This file already has two (ScrollHelper, the Pillow resample constant) and basketball has three, so a check that fired on any try/except would have failed all eight on day one. Also fixes the runner prefix: these scripts printed "FAIL name:", which run_plugin_tests.py does not match (it greps [FAIL] or FAILED), so CI reported the stream tail instead of naming the failing check. Now "[FAIL] name:". Verified both regressions are caught, by both the test and the gate: restoring the guard fails test_the_core_import_is_unguarded and trips the gate; restoring the copy fails test_the_bundled_copy_is_gone and trips it too. hockey 20/20; fleet 222 passed, 2 skipped, 1 failed -- unchanged, that failure being football's pre-existing test_favorite_live_boost.py, which reproduces on a clean origin/main. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude <noreply@anthropic.com> * fix(hockey-scoreboard): keep this PR to selection, not the scroll sunset The merged branch carried two feature versions. 1.21.0 is the selection work this PR is for; 1.22.0 is the B6 scroll-fallback sunset, which has its own PR (sunset/hockey-scroll-fallback) and raises the core floor to 3.2.0. Shipping them together would have floored every user at 3.2.0 to get a selection fix, and advertised a sunset in the manifest while the bundled copy was still present. Hockey drops to 1.21.0 at >=2.0.0; scroll_display.py, scroll_display_legacy.py and test_core_fallback.py return to main's copies; the sunset-only test_core_scroll.py and the SUNSET_PLUGINS edition of check_scroll_adoption.py go back to main's versions too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(scoreboards): drop the redundant `or {}` in the ranked-quality check `(getattr(self, "_team_rankings_cache", None) or {}) and not ...` is truth-identical to `getattr(...) and not ...` in all three cases -- None, an empty dict, and a populated one -- since `{}` is falsy either way. Codacy flagged it once per lineage, which is the whole of its "8 new issues" on this PR. Football expresses the same guard as `self._rankings_loaded()`; the siblings have no such helper, so this keeps the inline form rather than porting a method to eight copies for a one-line simplification. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Added 6 new plugins to plugins.json:
Total plugins in registry: 8 (including hello-world and clock-simple) All new plugins are on simple-plugins branch