Skip to content

feat(sports): prioritise favourites even when they are not exclusive - #332

Merged
ChuckBuilds merged 3 commits into
mainfrom
feat/prioritise-favorites-when-not-exclusive
Aug 26, 2026
Merged

feat(sports): prioritise favourites even when they are not exclusive#332
ChuckBuilds merged 3 commits into
mainfrom
feat/prioritise-favorites-when-not-exclusive

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

"I want to see my favorites and other teams, not ONLY my favorites, which is why it was off. Shouldn't we still prioritize favorites in upcoming and recent even if 'show favorites only' is off?"

They should have, and they did not. show_favorite_teams_only was the whole story: on, and you saw nothing but your teams; off, and your teams were ignored entirely — selection just took the next N games league-wide. There was no way to ask for "my teams, plus some others", which is what most people actually want.

Measured, not asserted

Against a real board's cached NCAA schedule (946 upcoming games, 119 involving a configured favourite, favourites = UGA, AUB, AP_TOP_10, upcoming_games_to_show: 3):

TODAY  -> 3 cards (0 favourite, 3 other)
    UNC @ TCU    Sat 29 Aug
   SJSU @ USC    Sat 29 Aug
   NCSU @ UVA    Sat 29 Aug

WITH FIX, other=1  -> 4 cards (3 favourite, 1 other)
    UNC @ TCU    Sat 29 Aug
 * UTEP @ OU     Sat 05 Sep
 *  MIA @ STAN   Sat 05 Sep
 *  UNT @ IU     Sat 05 Sep

119 favourite games available; zero of them reaching the panel.

The design

_favorites_first puts favourites first, tops up with other_upcoming_games_to_show / other_recent_games_to_show others, then re-sorts by time so the cards still read as a schedule rather than favourites-then-the-rest.

Both limits are totals, not per-team budgets. In favourites-only mode upcoming_games_to_show is per team — reasonable when the list is your own handful of teams. Applied to a dynamic group it is not: AP_TOP_10 resolves to a dozen teams, and three games each is 28 cards before a single other game is added.

Defaults are the league-wide counts already configured, so a board that upgrades keeps every game it was showing and simply gains its favourites. The change is additive — never a removal. 0 opts into favourites-only.

Scope

All nine sports plugins, per the shared-code rule. Three of them (afl, nrl, soccer) carry an older recent-games branch with a different log line and needed their own rewire — worth knowing for the next person who greps for one anchor and assumes nine hits.

Verification

gate result
new test, all 9 plugins 12 checks each, 0 failed
ablation (old chronological take) 8 of 12 fail
safety harness, all 9 200 renders, 0 fail
run_plugin_tests.py --all 183 passed, 2 skipped, 0 failed
module collisions clean across 43 plugins
manifest == versions[0] == registry aligned, all 9

test_favorites_log_says_which_case.py was updated rather than left passing — the branch whose wording it guarded no longer exists in that form, and it failed on exactly the two checks describing the old behaviour.

🤖 Generated with Claude Code

https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

"I want to see my favorites and other teams, not ONLY my favorites, which is
why it was off. Shouldn't we still prioritize favorites in upcoming and recent
even if 'show favorites only' is off?"

They should have, and they did not. show_favorite_teams_only was the whole
story: on, and you saw nothing but your teams; off, and your teams were ignored
ENTIRELY -- selection just took the next N games league-wide. Measured against
a real board's cached schedule: 946 upcoming college games in the window, 119
of them involving a configured favourite, and the three cards actually shown
contained none of them. There was no way to ask for "my teams, plus some
others", which is what most people want.

_favorites_first is that middle setting: favourites first, then up to
other_{upcoming,recent}_games_to_show other games, re-sorted so the cards still
read as a schedule rather than favourites-then-the-rest.

Both limits are TOTALS here, not per-team budgets. In favourites-only mode
upcoming_games_to_show is per team, which is reasonable when the list is your
own handful of teams; applied to a dynamic group it is not -- AP_TOP_10
resolves to a dozen teams, and three games each is 28 cards before a single
other game is added.

Defaults are deliberately the league-wide counts already configured, so a board
that upgrades keeps every game it was showing and simply gains its favourites.
The change is additive; 0 opts into favourites-only.

Ported to all nine sports plugins in this PR per the shared-code rule. Three of
them (afl, nrl, soccer) carry an older recent-games branch with a different log
line and needed their own rewire.

Ablation: replacing _favorites_first with the old chronological take fails 8 of
the 12 checks. The existing wording test was updated rather than left passing --
the branch it described no longer exists in that form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3dc31789-f852-477f-8983-fe31914eba7a


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Aug 26, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 504 complexity

Metric Results
Complexity 504

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

claude added 2 commits August 26, 2026 16:58
afl, nrl and soccer keep their game limits at the root of the schema rather
than in a per-league game_limits block, and the root has an explicit
x-propertyOrder. Anything missing from it is dropped from the form entirely, so
the two new settings existed in the schema and were invisible in the UI --
caught by scripts/test_property_order_coverage.py, which is exactly the failure
it is there to catch.

Each key is placed immediately after the setting it modifies, so the form reads
"N games, then N others" rather than stranding it at the end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
…over

Enlarging the selection is the obvious way to see more matchups and it does the
opposite. The panel already cycles the selected list -- index advances every
upcoming_game_duration and persists across visits -- so the limit is a POOL
SIZE, not a card count. With a 20s slot and 15s cards that is roughly one card
per visit, so a 119-game pool needs ~119 visits per lap and makes any given
game rarer, not commoner. It also costs one ESPN odds request per pooled game,
each blocking up to 1.5s in _fetch_odds.

So the pool stays short and the non-favourite slice moves instead. The window
advances by its own width, so consecutive windows are disjoint and the board
walks the schedule rather than resampling the front of it, and it wraps so a
short list still cycles.

Measured on a real board's cached schedule, favourites UGA+AUB with 3 others
rotating every 30 minutes: 18 distinct other matchups in three hours, pool
still 6 cards, lap still ~90s of airtime, favourites present in every window.

Advancing is time-based rather than per-update. update() runs every 30s; a
window that moved with it would change the games list identity on every pass,
reset the display index to 0, and no card past the first would ever be reached.
It advances by however many intervals actually elapsed, so a mode that was not
displayed for an hour catches up instead of crawling one step.

Favourites are deliberately NOT rotated: for upcoming games the soonest ones
are the point, and rotating them would show a week-8 fixture instead of
Saturday's. 0 pins the window, restoring the fixed "next N others".

Also supplies the new attributes to test_favorites_log_says_which_case's probe,
which builds its object with __new__ -- a missing attribute raises inside
update()'s own try/except, so the log line silently never appears and the
failure reads as a wording change rather than an incomplete object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
@ChuckBuilds
ChuckBuilds merged commit c9da3fe into main Aug 26, 2026
4 checks passed
ChuckBuilds added a commit that referenced this pull request Aug 30, 2026
…works (#335)

* feat(football): favourites first, ranked others, and a rotation that works

Football-scoreboard only. The same work is prepared for the other eight sports
plugins and is deliberately held back so each can be judged on its own board
rather than nine changing at once.

Selection
- Five settings added in #332 never reached the display code. Managers read a
  translated config and that translation is an explicit whitelist, so every one
  of them was declared in the schema, rendered in the UI, read by sports.py and
  dropped in between: set it, save it, nothing happens.
- Other games are filtered by quality -- ranked by default -- instead of taken
  in kickoff order. On a real board's college slate, 923 non-favourite upcoming
  games, 235 of them involving a ranked team.
- Within that pool the better matchup leads, and each team appears once. The
  upcoming list is a whole SEASON, not a week -- ledpi logs 947 games -- so
  ordering by rank alone put the #1 team's twelve games above the #2 team's
  first one, and the board walked one team's schedule.
- Favourite slots are shared between your teams: each gets its next game before
  any gets a second. Walked across a real 901-game season with two favourites
  at a limit of 2, nine days showed one team twice and the other not at all.
- The FBS/FCS filter needs one side in a checked division, not both. Requiring
  both silently removed five of twenty ranked matchups -- a ranked side hosting
  an FCS school is still a game about a team you asked for.
- ncaa_fb defaults to five games with no favourite team, rather than one.

Data
- College rankings came from the wrong endpoint. /standings was tried first and
  /rankings only on a 404, but college football answers /standings with 200 and
  no rankings key, so the fallback never fired: the rank badge never appeared
  and the ranked filter passed everything, an empty table failing open.
- Each poll is fetched once, not once per configured group.
- Leagues with no poll no longer request one -- roughly 2,900 dead requests a
  day, per league, at a 30s interval.
- The FBS/FCS group lookup runs for college football alone, the one league ESPN
  publishes those rosters for. Everywhere else it 500s or returns an empty list.
- The division cache expires instead of being held for the life of the process.

Pacing
- The rotation now re-cuts the slice on the display path. It only ever ran from
  update(), which returns early until upcoming_update_interval -- an hour, and
  not settable -- so a four-minute rotation produced one jump of fifteen
  windows once an hour. ledpi showed the same two matchups 89 times in six
  hours; it now cycles the ranked slate in about twenty minutes.
- recent_update_interval, upcoming_update_interval and stale_game_timeout are
  declared and translated. They were read by the code and reachable by nobody.

Safety
- The filters fail open as a set, not only per check: if they leave nothing at
  all, the unfiltered list is used rather than blanking the mode. Asking for 0
  other games is still an explicit "favourites only".
- "broadcast" reads coverage off the slate. The payload always carries the key
  and it is simply empty in leagues ESPN publishes no listings for, so the
  usual missing-means-allowed reading never fired and the setting removed every
  non-favourite game there.
- A loaded poll that matches no game on the schedule now warns, throttled, and
  names the abbreviations it holds.
- Config values are clamped to their declared ranges. A string where an integer
  belongs raised inside update()'s own try/except, which surfaces as a mode
  that renders nothing.

Repo guards
- scripts/check_selection_settings.py finds every settings block structurally
  and requires each setting the plugin's own code reads. Deriving the
  requirement from the code is what lets these nine plugins be ported one at a
  time without a red build.
- run_plugin_tests names the checks that failed instead of the last line
  logged, which for a script that warns on stderr was the warning, pass or fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(football): build the resolver's kwargs once, not a keyword it may not take

Codacy flagged `DynamicTeamResolver(cache_manager=cache)` as an unexpected
keyword, and it is right about the code as written. Five plugins ship a class
of that name and three of them -- basketball, hockey, lacrosse -- take no
cache_manager at all, so `from dynamic_team_resolver import ...` gives an
analyser a call it can prove wrong for whichever class the bare module name
resolved to. The runtime guard made it safe; it did not make it checkable.

The signature is now inspected once and the argument passed as **kwargs, which
also stops the same condition being written twice -- once for the first
resolver and once for the second, where the two spellings could drift apart.

Verified against both shapes: football's own resolver, and hockey's swapped in,
which takes no cache_manager. The second constructs cleanly and then fails the
three behavioural checks, because hockey has not been ported yet and still keys
its cache per pattern rather than per sport -- which is the bug this PR fixes,
and exactly what that test exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(football): act on the review -- one selection path, and two silent failures

Five of the eight findings held up. The two most serious were mine and recent.

1. The adapter coerced the divisions list before the coercion helper could see
   it: `list(game_limits.get("other_games_divisions", ["fbs"]))`. A hand-edited
   "fbs" became ['f','b','s'] -- already a list, so _normalise_divisions' string
   branch never fired -- and the filter then intersected against three letters
   that name no division and rejected every non-favourite game. A null was
   worse: list(None) raises inside _adapt_config_for_manager, which
   _initialize_managers catches and logs once, leaving all six managers None
   and the plugin enabled but rendering nothing. Passed through raw now.

2. The no-favourites branch had its own selection path -- filter, sort,
   truncate -- and so never built the selection pools. Nothing rotated, nothing
   was ordered by rank. That is the DEFAULT configuration for college football,
   and this PR had just made it the default for five games, so the board most
   in need of the pacing work was the one board not getting it. Both branches
   now call _favorites_first, the no-favourites case with a favourite limit of
   0, which retires _filtered_or_all: one path, one fallback.

3. fetch_standings suppressed every non-HTTP failure. `status not in (404,
   None)` reads None for ConnectionError, Timeout and a body that will not
   parse, so a board that could not reach ESPN said one debug line and ran the
   ranked filter against an empty table. Only 404 is routine now.

4. The division roster asked for datetime.now().year. College football's 2026
   season runs into January 2027, when that returns groups which do not exist,
   the roster comes back empty and division filtering fails open -- through the
   bowls and the playoff. Derived from the season instead.

5. The window advance is a read-modify-write with two writers -- update() and
   the display path -- and neither held a lock at that point. Interleaved, both
   see the interval elapsed and each add a width, skipping a window nobody
   sees. Now under _games_lock, which is an RLock and which the display path
   takes again immediately after.

6. The rotation gated on `others`, but the whole-set fallback slices
   `unfiltered`. In exactly the case that triggers the fallback the pool read
   as empty and the fallback was pinned until the next fetch.

Two findings I did not take:

- Odds are fetched for the selection, so a card rotated in between fetches
  renders without a spread. I widened the fetch to cover the rotation pool and
  scripts/test_odds_fetch_scope.py failed -- correctly. That guard exists
  because a rig once made 946 odds requests to put one game on the panel, and
  widening the scope inside update() is exactly what it forbids. Reverted; the
  gap is real but belongs in a change that revisits that decision deliberately,
  not in this one.
- The README's default for upcoming_games_to_show was stale against the new
  ncaa_fb schema default. Corrected -- that one I did take.

The odds guard needed a change of its own: it located the trim by searching for
the literal slice, which merging the two selection branches moved inside the
helper. It now accepts either shape, and still fails when the fetch is pointed
back at the collected list, which is the bug it was written for.

Two probes built with __new__ needed the lock supplied, which is the failure
mode their own comments warn about: a missing attribute raises inside update()'s
try/except, and the test reads as a wording change rather than an incomplete
object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(football): stop the due-check recomposing every frame, and tell the truth about game counts

The rotation due-check guessed which pool _compose_selection would slice
as `others or unfiltered`, but the composer only falls back to the
unfiltered list when favourites contribute nothing. With a favourite
playing and the filters rejecting every other game, the guess said
'rotate', the recompose produced an identical favourites-only list, the
rotation clock never advanced, and selection re-ran on every display()
call forever. The check now mirrors the composer's rule; the new test
fails on the old expression and no other check.

The games_to_show schema descriptions still said 'N games per favorite
team', which is only true with show_favorite_teams_only on -- in the
default favourites-first mode they are totals. Reworded, and the
'additive, never a removal' comment scoped to the counts it is true of.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YF7Q48EYCCkU1Vs932uDY1

* fix: address the CodeRabbit review

All six findings verified against the branch before fixing:

- broadcast is coerced to a string at extraction; ESPN's undocumented
  payloads have served it as an object, and a non-string reached
  _note_broadcast_coverage's .strip() inside update()'s try/except.
- _setting_int also catches OverflowError: json parses a bare Infinity
  and int(inf) raises from __init__, outside any fallback.
- check_selection_settings rejects unknown plugin ids up front instead
  of printing OK and then crashing on the block count.
- run_plugin_tests keeps a labelled tail from each output stream;
  concatenating them reported only stderr when both had evidence.
- README: the AP_TOP_10 example now says 'up to 12 favorites' with the
  overlap caveat, and the rotation example fence names a language.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YF7Q48EYCCkU1Vs932uDY1

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chuck <chuck@example.com>
ChuckBuilds pushed a commit that referenced this pull request Aug 31, 2026
… this

Main moved a long way under this branch. Resolving hunk by hunk showed the
work splits cleanly in two, and the halves want opposite resolutions.

**football-scoreboard is fully superseded.** What this branch does for football
shipped as #335 and was then improved by #341, #343 and #344. Main is strictly
ahead everywhere they overlap: it season-corrects the division roster lookup,
str()-guards ESPN's broadcast field, catches OverflowError on a bare Infinity,
routes the no-favourites branch through _favorites_first so it builds selection
pools, and adds _attach_odds_to_rotated_games, which this branch does not have
at all. Several of main's comments describe this branch's own approach as the
old way. Every football hunk therefore takes main, and football's sports.py,
manager.py, data_sources.py, tests and README now match origin/main byte for
byte -- this branch no longer changes football in any way.

**The other eight lineages still need all of it.** They have _favorites_first
and nothing else: no _compose_selection, no rotation, no _passes_other_filters.
Their settings were in the schema with nothing reading them. Those hunks take
this branch.

Three resolutions worth naming:

- baseball, basketball, hockey and lacrosse: main re-adds _is_favorite_game
  beside the rotation methods this branch adds. Taking both would have defined
  it twice in one class, and Python takes the last -- main's copy would have
  silently shadowed this branch's. Kept one, plus main's _DRAWS_SCORE ClassVar.
- nrl: main defines _is_favorite_game TWICE in SportsCore, at 320 and 2177, and
  they do not agree -- the first matches on ESPN team id, the second on
  abbreviation. #189 moved nrl to ids deliberately; #332 added the abbr copy,
  which shadowed and silently reverted that fix. This branch removes the
  duplicate, so the fix is restored. That is a live bug on main today.
- Manifests and plugins.json take main, then the eight changed plugins are
  re-bumped on top of the versions main has since published. Football is not
  bumped, because nothing about it changed.

READMEs: football takes main; afl, baseball and basketball keep both sections,
since this branch documents selection and main documents the matchup separator.

Verified: run_plugin_tests.py --all gives 222 passed, 2 skipped, 1 failed, and
that one failure -- football's test_favorite_live_boost.py, "excluded team
hidden from recent/final scores in default (no-favorites) path" -- reproduces
identically on a clean origin/main worktree. It is main's, not this merge's:
_favorites_first(games, 0, N) does not apply the exclude filter the old
filter/sort/truncate path did. The eight apply excludes elsewhere and pass.
check_selection_settings, check_manifest_version_fields, check_scroll_adoption
and check_module_collisions all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants