diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c3fbe6..7f729e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,112 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +## 3.8.2 + +The display hands freed memory back to the OS (#774), and sports consolidation +family 6: `src.common.sports_favorites`, which the scoreboards adopt by +flooring on 3.8.2 (#775). + +### The display hands freed memory back to the OS + +The display process's resident memory climbed in steps for hours while the +data it held stayed flat: glibc keeps what Python frees in per-thread malloc +arenas and returns little of it. `src/malloc_tuning.py` (new, standard library +only, a no-op off Linux/glibc) does two things in-process, so it reaches +devices without re-running the installer: + +- **Arena cap at start-up.** `run.py` calls `mallopt(M_ARENA_MAX, 2)` before any + thread exists, the same cap as the unit's `Environment=MALLOC_ARENA_MAX=2`. + Units installed before that line never got it (systemd runs the copy in + `/etc/systemd/system`); a `MALLOC_ARENA_MAX` in the environment still wins. +- **`malloc_trim(0)` between screens**, at most every 5 minutes, from the top of + the render loop where no frame is being drawn. Measured on a Pi 4: 2-11 ms + per call. + +On ledpi (Pi 4, 192x48, Vegas on, nine plugins, a unit without +`MALLOC_ARENA_MAX`), alternated main / branch / branch / main arms of 2.5 h: +two hours in, resident memory was 551 MB on main (the second main arm was +already at 651 MB after 1 h 44 min) against 412 and 386 MB with this change, +and the 20-minute frame soaks came out at 0.147-0.165% late against main's +0.151-0.188%. + +### New modules + +- `src/common/sports_favorites.py` -- sports consolidation family 6, once the + plugins made `_is_favorite_game` (seven bodies), `_select_games_for_display` + (two) and `_select_recent_games_for_display` (three) one each: + `SportsFavoritesMixin` (`SportsCore`: `_is_favorite_game`, `_favorite_code`), + `SportsUpcomingFavoritesMixin` and `SportsRecentFavoritesMixin` (the + favourites-only picks). Each side of a game is named by the 3.5.0 + `_favorite_key` seam and compared with `favorite_teams` stripped and + upper-cased; nrl overrides the key with the ESPN team id. Only a game with an + id can be a duplicate. A plugin may inherit the mixins once it floors on + 3.8.2, and deletes its copies then. (#775) + +## 3.8.1 + +Smooth scrolling at the slower speeds, and the fixes and performance work +since 3.8.0. Highlights: the default 50 px/s and every other held-frame speed +now scroll cleanly (below), Raspberry Pi OS Bookworm is supported alongside +Trixie, updates refresh the systemd units, the display control socket gains +stages 2 and 3, the shared fetch service lands (stages 1 and 2), and a run of +web UI and Plugin Manager fixes. One new module is for plugins: +`src.common.sports_game_over` (sports family 5), which the scoreboards adopt +by flooring on 3.8.1; the other new modules are core-internal and set no +`ledmatrix_min_version` floor. + +### Scroll speed + +These two entries were the reason for this release: on 3.8.0 a slow scroll +either stepped or showed a half-pixel tear across the middle of the panel, +so only speeds of one pixel per refresh looked right. + +- The Vegas Scroll Speed slider now says what the panel will do with the speed + it is on, and offers the nearest smooth ones to click. Only speeds that advance + a whole number of pixels per refresh look smooth, and which those are depends + on the panel (`GET /api/v3/config/scroll-speed-advice`, built on + `scroll_config.speed_advice()`; it uses the refresh the display measured, not + the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5. +- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5 + refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s, + which move one pixel at a time. 100 Hz panels are unaffected. (#710) +- A held-frame scroll (one pixel every two or more refreshes, such as 50 or + 60 px/s on a 100-120 Hz panel) no longer shows a half-pixel step across the + middle of the panel. Scan-order compensation ran only at one frame per + refresh; a held frame is now presented as a sequence of swaps + (`scan_order.refresh_plan()`), so the half of the panel that scans later + steps one refresh after the rest. It is skipped when a blit takes more than + half a refresh, since the second blit has to land before the next vsync. + (#711) + +### Web UI: the Display tab is an ES-module page, with a page-visibility service (stage 4) + +- New `static/v3/js/core/visibility.js`: each page module gets + `ctx.visibility` with `whileVisible(start, stop)`, `every(ms, fn)` and + `isVisible()`. Work registered there runs only while the page's tab is the + active tab and the browser tab is visible, and ends when the page is + swapped out, with no teardown code in the page. It reads the active tab + from `window.LEDVisibility`, so it agrees with the classic partials that + still use that directly. The page registry gained a `mountContext` option + for services bound to one mounted page. +- The Display tab's inline scripts are now `static/v3/js/pages/display.js`. + The partial has no inline script, `onclick` or `onchange` any more. The + multi-display sync status poll (every 5 s) runs through + `ctx.visibility.every`; the status and scroll-speed hint requests go + through `core/api.js` with the page's abort signal, and so does the Vegas + order widget's plugin-list request. +- Behaviour differences: with sync on, opening the tab asks for the status + once instead of twice, and a Display tab loaded while not on screen waits + until it is. A login redirect during a poll no longer flashes "Sync status + unavailable". The `window.syncStatusInterval` timer id is gone (nothing + read it). A pending scroll-hint request or widget retry is dropped when + the partial is swapped out. +- `window.updateSyncUI` keeps working as a deprecated alias through + `window.LEDMatrix` (one console warning). +- New suites `test/js/dom/test_visibility_service.js` and + `dom/test_display_page.js`; `unit/test_display_partial_ids.js` imports the + module instead of slicing the template. + ### Plugins ask for the screen in-process: `request_on_demand()` / `end_on_demand()` The in-process way in that stage 5 of the control socket needed @@ -194,6 +300,20 @@ Internal; no behaviour change. Stage 3 of `docs/RUN_LOOP_REDESIGN.md`. (`refresh_registry_in_background()`, backing off for a minute after an offline failure), so a later load has them. The store, install and update paths still fetch as before. +- A sports live manager's idle back-off now honours every pending kickoff, + not just the first. `_note_scheduled_start_candidate()` kept one kickoff + and, while it was inside its 15-minute grace, refused every later one; by + the time the grace ended the later one had passed and was refused again. + So of two favourites kicking off within 15 minutes of each other, the + second lost its own grace: if the first game was not live by then (a rain + delay, a postponement, ESPN slow to flip it) and ESPN had not flipped the + second either, the back-off went back to its ceiling and the second game + was noticed up to the ceiling (15 minutes by default) late. Later + kickoffs now wait in a short queue (`_later_scheduled_starts`, the + earliest 8) and each takes over with a grace of its own when the one + before it expires. A kickoff still + holds the live cadence for at most its own grace, so a postponed game + costs the same quarter of an hour as before. ### ESPN date-range fetches: fewer requests, fewer at once @@ -668,6 +788,16 @@ policies are unchanged. - `src/display_arbiter.py` -- the display loop's Arbiter (see Tooling). Core-internal: plugins have no reason to import it, so it sets no `ledmatrix_min_version` floor. +- `src/common/sports_game_over.py` -- `SportsGameOverMixin`, sports + consolidation family 5: `_is_game_really_over`, the scoreboards' + `SportsLive` check that drops a game ESPN still lists as live, once the + plugins made their five bodies one. Over on a final period text, or on a + 0:00 clock from period `FINAL_PERIOD` on unless the score is level (a tie + at the end of regulation goes to overtime). `FINAL_PERIOD` is the per-sport + class attribute, `None` by default (the clock never ends a game); the + scoreboards declare 3 (hockey), 4 (basketball, football, lacrosse) or + `None`. List the mixin before `SportsLiveSharedMixin`. A plugin may import + it once it floors on 3.8.1, and deletes its copy then. (#770) ### Tooling @@ -1321,18 +1451,6 @@ guard the import, since the loader's version check is advisory). processes, or turns the socket off with `off`. A non-root dev run uses a private per-user path under the temp directory. -### Scroll speed - -- The Vegas Scroll Speed slider now says what the panel will do with the speed - it is on, and offers the nearest smooth ones to click. Only speeds that advance - a whole number of pixels per refresh look smooth, and which those are depends - on the panel (`GET /api/v3/config/scroll-speed-advice`, built on - `scroll_config.speed_advice()`; it uses the refresh the display measured, not - the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5. -- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5 - refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s, - which move one pixel at a time. 100 Hz panels are unaffected. - ### Update channels - Devices no longer pick up every merge to `main`. A new setting, @@ -1590,6 +1708,17 @@ read any of them: ### Fixes +- Updating a plugin from the store no longer deletes the files it wrote + beside itself. A monorepo update replaces the plugin directory with the + fresh download and deletes the old copy, so calendar's Google OAuth files + (`token.pickle`, `credentials.json`) were lost on every update and the + calendar stopped until they were restored by hand. Before the old copy is + removed, the update now copies over anything the plugin's `.gitignore` + excludes plus known secret/state files (`*.pickle`, `token.json`, + `credentials.json`, `config_secrets.json`, `.pkce_code_verifier`); files the + new release ships are never overwritten, and byte code is not carried. A + plugin updated with `git pull` no longer sweeps an untracked token into the + auto-stash, which is never popped (`src/plugin_system/plugin_local_files.py`). - Quieter routine logging. Every rotation logged each mode twice ("Switching to mode", then "Processing mode"), and a mode with nothing to show added "display() returned False" and "No content to display". Those diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index fe72fe06..4363e43d 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -87,10 +87,12 @@ more. Shared sports code lives in `src/common`: | `sports_celebration.py` | 3.7.0 | `SportsCelebrationMixin` — draws the score/win takeover; colour helpers | | `sports_fetch.py` | 3.7.0 | `SportsFetchMixin` — season fetch, live lookback and live-odds decisions | | `sports_card_wrappers.py` | 3.7.0 | `SportsCardWrappersMixin` — the game renderer's `sports_card` delegations | -| `sports_plugin_host.py` | next release | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh | -| `sports_live_scroll.py` | next release | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place | -| `sports_display_rules.py` | next release | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell | -| `sports_font_path.py` | next release | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return | +| `sports_plugin_host.py` | 3.8.0 | `SportsPluginHostMixin` — the plugin class's (`manager.py`) identical helpers: Vegas weight, off-thread switch refresh | +| `sports_live_scroll.py` | 3.8.0 | `SportsLiveScrollMixin` — rebuild a live scroll strip mid-cycle, keeping the marquee's place | +| `sports_display_rules.py` | 3.8.0 | `SportsCardOptionsMixin`, `SportsGameRulesMixin` — scorebug date options, the no-favourites filter, non-favourite live dwell | +| `sports_font_path.py` | 3.8.0 | `resolve_font_path` — what the plugins' `_resolve_font_path` copies return | +| `sports_game_over.py` | 3.8.1 | `SportsGameOverMixin` — `_is_game_really_over`, with the `FINAL_PERIOD` seam (family 5) | +| `sports_favorites.py` | 3.8.2 | `SportsFavoritesMixin`, `SportsUpcomingFavoritesMixin`, `SportsRecentFavoritesMixin` — `_is_favorite_game` and the favourites-only picks, on the `_favorite_key` seam (family 6) | Each is described in [src/common/README.md](../src/common/README.md). @@ -102,7 +104,8 @@ modules taken from the plugin copies, each a **new module** rather than growth on an existing one: a plugin that deletes a method copy and relies on an older module having gained it fails at runtime with an `AttributeError`, while a missing module fails at load, where the version checks can see it. -`sports_helpers.py` holds `_favorite_key`, the override point listed below. +`sports_helpers.py` holds `_favorite_key`, the override point listed below; +`sports_favorites.py` is what calls it. Each promoted module has a parity test that compares its bodies against the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout (`test_sports_helpers.py`, `test_sports_stage3_parity.py`), and @@ -125,7 +128,7 @@ deprecation cycle. | `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op | | `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `" SCORES!"` — only consulted when `CelebrationMixin` is present | | `win_phrase(team_abbr)` | Win-celebration wording | `" WINS!"` — mixin only | -| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["_abbr"]` | +| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching. `sports_favorites` compares it, and each `favorite_teams` entry, stripped and upper-cased; a `None` matches nothing | `game["_abbr"]`. nrl returns the ESPN team id, `None` when it is missing | | `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read | | `_font_root()` | Directory to resolve `assets/fonts` against | core install root | @@ -134,8 +137,7 @@ constants rather than behavior: | Attribute | Meaning | Default | |---|---|---| -| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) | -| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) | +| `FINAL_PERIOD` | Period from which a 0:00 clock ends a game (`sports_game_over`) | `None`: the clock never ends a game (afl, nrl, soccer, baseball, ufc). Hockey sets `3`; basketball, football and lacrosse `4` | | `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) | ### Why these are seams and not branches @@ -146,11 +148,14 @@ so NRL matches favorites on team ID. Flattening every plugin to abbreviations would silently select the wrong club for NRL users. The base declares the seam, NRL fills it, and core never learns the string `"nrl"`. -`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a +`FINAL_PERIOD` exists for the same reason in the opposite direction: a soccer clock reading `0:00` means the match has not kicked off, so running the -clock-expiry branch there would evict live games. +clock-expiry rule there would evict live games. Those sports declare `None`, +and so do baseball (innings, not a clock) and ufc (a bout ends only on ESPN's +final status). One attribute covers both questions, whether the clock can end +a game and from which period, so no separate count-down flag was added. -`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one +`COALESCE_SCORING_SEQUENCE` is another of the same kind. In football one scoring play arrives as two score updates, so the follow-up must be folded into the first celebration; in soccer two increments a few seconds apart are two real goals, and folding them would swallow one. Neither default is "right" — which is @@ -298,6 +303,49 @@ Left in the plugins, though identical: renderers) is already core's, in `SportsHelpersMixin`; a renderer that wants it can inherit that. +### Family 5: the game-over check (core done; adoption waits for a release) + +The pilot of the method below. ledmatrix-plugins `scripts/test_game_over_check.py` +(#621) pinned 3,115 answers across the nine plugins first; the reconcile +(ledmatrix-plugins #625) made the five bodies one and +changed only the cells the owner's decisions under +[Product decisions](#product-decisions-each-family-needs) explain: ufc's +clock rule (65 cells), baseball's dormant one (53, every one a game with a +`period` baseball's games never carry), and a level score at 0:00 (five +cells in hockey, basketball, football and lacrosse). The harness renders +were pixel-identical. `src/common/sports_game_over.py` holds the body; +`test/test_sports_game_over_parity.py` compares it, and each plugin's +`FINAL_PERIOD`, with the plugin copies. + +### Family 6: favourite matching (core done; adoption waits for a release) + +ledmatrix-plugins `scripts/test_favourite_matching.py` (#634) pinned 204 rows +across the nine plugins first: `_is_favorite_game` on each manager role, the +two selection methods, the real `update()` with favourites-only on and off, +and the INFO summary; the reconcile extends it to 217 (a lower-case and a +padded favourite through `update()`, and the live favourite boost). The reconcile (ledmatrix-plugins +#635) made `_is_favorite_game` one body on `SportsCore` +(afl and soccer's `SportsUpcoming` copies and five `SportsLive` copies, all +redundant, are gone), added `_favorite_code` beside it, and gave nrl a +`_favorite_key` override instead of its own copies. So that a lower-case +favourite works on a favourites-only Upcoming board, the Upcoming `update()`'s +favourites-only pre-filter and the basketball, hockey and lacrosse live boost +now ask `_is_favorite_game` too (a one-line change each; `update()` itself is +family 13). Of 3,897 cells only those the decisions above explain changed: +case and spaces in eight plugins (30-40 each), the id-less duplicate fix (6-8 +each), nrl's key (6) and its "None" match (6), and the INFO line in baseball, +football and ufc. The harness renders were byte-identical. `src/common/sports_favorites.py` holds the +bodies, one mixin per carrying class; `test/test_sports_favorites_parity.py` +compares them with the plugin copies and checks that only nrl overrides +`_favorite_key`. + +Left for later families: the live screens' favourites-only filter +(`_classify_live_game` and its inline copies) and favourites-first sort still +compare abbreviations exactly, and +`SportsCoreSharedMixin._round_robin_favorites` groups favourites by raw +abbreviation (or by `_team_in` where a plugin has one) instead of through +`_favorite_key`. The result-colour helpers also wait (decision above). + ### Why the method changes Byte-identical promotion has nearly run dry. Measured on ledmatrix-plugins @@ -333,8 +381,8 @@ game-over check); the report measures each method in it. The procedure: line in each plugin. - *A per-sport fact* (hockey ends in period 3; a soccer clock counts up). Make it a declared class constant or override point with a default, as - `FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`, `COALESCE_SCORING_SEQUENCE` and - `_favorite_key` are, and add it to the tables above. Never a sport-name + `FINAL_PERIOD`, `COALESCE_SCORING_SEQUENCE` and `_favorite_key` are, + and add it to the tables above. Never a sport-name branch: core must not learn sport names. - *A product difference*: anything a user can see (which games show, a colour, a date, a badge, how long a screen stays). The owner picks the @@ -382,8 +430,8 @@ release. | # | Family | Methods (variants) | Why here | |---|---|---|---| | 4 | Identical sweep | `manager.py`: `_dispatch_switch_refresh`, `_favorite_team_is_live`, `get_vegas_priority_weight`, `_game_involves`, `_favorite_scan_targets`, `_favorite_scan_games`, `_get_total_games_for_manager` (all nine, 1); the live-scroll helpers `_preserving_scroll_position`, `_refresh_live_scroll_managers`, `_live_scroll_managers`, `_note_live_scroll_built`, `_live_scroll_needs_rebuild`, `_live_scroll_fields` (eight, 1). `sports.py`: `_card_option`, `_filtered_or_all`, `_effective_live_duration`, `_recent_date_text` (eight, 1). 58 identical families in all | Nothing to decide; brings `manager.py` into core as a `SportsPluginHostMixin`. `_resolve_font_path` (identical in nine `sports.py` and eight renderers) becomes `sports_font_path.resolve_font_path`, not `font_layout.resolve_asset_path`, which skips the cwd. Core side done; see [Stage 4](#stage-4-the-identical-sweep-core-done-adoption-waits-for-a-release) | -| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; its seams (`FINAL_PERIOD`, `CLOCK_COUNTS_DOWN`) were designed in B1. The pilot for the procedure | -| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam | +| 5 | Game-over check | `SportsLive._is_game_really_over` (5) | Pure logic, no pixels; one seam, `FINAL_PERIOD`. The pilot for the procedure. Reconciled to one body and promoted as `sports_game_over`; adoption waits for the release that ships it. See [Family 5](#family-5-the-game-over-check-core-done-adoption-waits-for-a-release) | +| 6 | Favourite matching | `_is_favorite_game` (7 across three classes), `_select_games_for_display` (2: nrl), `_select_recent_games_for_display` (3) | Everything that asks "is this a favourite" goes through the 3.5.0 `_favorite_key` seam. Reconciled to one body each and promoted as `sports_favorites`; adoption waits for the release that ships it. See [Family 6](#family-6-favourite-matching-core-done-adoption-waits-for-a-release) | | 7 | Other-games rotation | `_by_importance`, `_other_games_window`, `_advance_other_games_if_due` (2 each: football), `_rotate_other_games_on_display` (2: ufc) | One outlier each; football carries two fixes the other eight lack | | 8 | Rankings | `_fetch_team_rankings` (3), `_choose_poll` (3), `_load_division_team_ids`, `_passes_other_filters`, `_best_rank`, `_is_ranked_game` (2 each: football) | Needs 7; the rank badge and the "ranked only" filter read it | | 9 | Live fetch and odds | `_fetch_todays_games` (5), `_fetch_odds` (3), `_attach_odds_to_rotated_games` (3) | The prerequisite for one shared ESPN poller across plugins | @@ -416,21 +464,28 @@ family 9 prepares. Owner calls to make before (or while) reconciling. Items marked *verify* are suspected behaviour that needs a payload or a rig to confirm first. -- **5, game-over check.** Which rule each sport gets: the clock never ends a - game in afl, nrl and soccer (`CLOCK_COUNTS_DOWN = False`); hockey ends at - 0:00 from period 3, basketball, football and lacrosse from period 4. - baseball and ufc share a copy that reads a missing clock as "0:00": dormant - in baseball (its games carry no `period`), and not triggered by ufc's round - breaks either. ESPN sends a break as `STATUS_END_OF_ROUND` with displayClock - `-`, not `0:00` (verified against recorded payloads; ledmatrix-plugins#580 - pins it). Whatever rule ufc gets must not read `-` as `0:00`. Decide ufc's - rule: no clock rule (ESPN's `STATUS_FINAL` is the only end signal it needs; - this also closes a ~1 s window at the horn when the ticking clock reads - `0:00`), or its own final period. -- **6, favourite matching.** NRL keeps matching favourites by team id - (abbreviations collide: NEW, CAN), through `_favorite_key` rather than its - own copies of the selection methods. Six plugins log the recent-games - selection at INFO; baseball, football and ufc do not. +- **5, game-over check. Decided 2026-10-05, done:** one seam, + `FINAL_PERIOD`: hockey 3; basketball, football and lacrosse 4; `None` (the + clock never ends a game) for afl, nrl and soccer (clocks that count up), + baseball (its games carry no `period`, so the old rule was dormant) and + ufc (a bout ends only on ESPN's final status, which also closes the ~1 s + window at the horn when the ticking clock reads `0:00`; ESPN's round-break + displayClock `-` was never a zero clock, ledmatrix-plugins#580). Only a + non-empty clock string counts (the baseball/ufc copy read a missing clock + as `0:00`). A score level at 0:00 is not over: the game stays live through + the break before overtime, and one that really ends tied ends on its final + status. Baseball keeps its postponed/suspended override in `BaseballLive`. +- **6, favourite matching. Decided 2026-10-05, done:** each side of a game is + named by `_favorite_key` (the abbreviation; NRL overrides it with the ESPN + team id, and `None` for a missing id, which fixes a favourite typed "None" + matching every game without one) and compared with `favorite_teams` + stripped and upper-cased, so " bos" matches BOS. NRL's ambiguous "NEW" + still matches nothing and is logged; routing the result-colour helpers + (`side_is_favorite`, which tint both NEW clubs) through `_favorite_key` is + left for a later family. The recent-games selection logs at INFO in all + nine. ufc stays on the shared body, dormant: its favourites are fighters, + which its MMA managers match themselves (a follow-up). Fix ported: only a + game with an id can be a duplicate in the selection methods. - **7, other-games rotation.** football advances the rotation window under `_games_lock` (update() and display() both advance it; interleaved, a window of games is skipped) and fixes a favourites-only pool that recomposed diff --git a/docs/WEB_FRONTEND_ARCHITECTURE.md b/docs/WEB_FRONTEND_ARCHITECTURE.md index b7ae12ba..962107da 100644 --- a/docs/WEB_FRONTEND_ARCHITECTURE.md +++ b/docs/WEB_FRONTEND_ARCHITECTURE.md @@ -42,7 +42,8 @@ static/v3/js/ registry.js page lifecycle: init/destroy on htmx swaps api.js fetch wrapper for /api/v3 (JSON envelope, login redirect) facade.js window.LEDMatrix and deprecated aliases - (later) escape.js, notify.js, dialog.js, streams.js, visibility.js, + visibility.js ctx.visibility: a page's timers run only while it is on screen + (later) escape.js, notify.js, dialog.js, streams.js, store.js (the one installed-plugin store), form/renderer.js pages/ one module per tab partial cache.js export init(root, ctx), destroy(root, ctx) @@ -81,6 +82,12 @@ The conventions the converted pages share: to the module's export of the same name and warns once. - **Timers are cleared in `destroy()`**, the one thing `ctx.signal` cannot undo by itself. +- **Polling goes through `ctx.visibility`.** A refresh that repeats + (`ctx.visibility.every(ms, fn)`) or work that should run only while the + page is on screen (`ctx.visibility.whileVisible(start, stop)`) is + registered there, never with a bare `setInterval`. It runs only while the + page's tab is the active tab and the browser tab is visible, and it ends + when the page is destroyed, with no code in `destroy()`. - **A page reports its own htmx saves.** A form whose result a page module shows (an `htmx:afterRequest` listener on the page root, in place of an `hx-on` attribute naming a global) carries `data-reports-result`. `app.js` @@ -111,6 +118,7 @@ Each mount gets a `ctx` object: | `ctx.state` | A per-mount object for the page's own state | | `ctx.api` | Shared service from `boot.js` | | `ctx.notify` | Shared service from `boot.js` | +| `ctx.visibility` | This page's handle on `core/visibility.js` (below), made per mount by `boot.js` through the registry's `mountContext` option | A page that passes `{ signal: ctx.signal }` to `addEventListener` and `fetch` needs no teardown code. Its listeners and in-flight requests go @@ -119,6 +127,30 @@ example: its delete buttons use one delegated listener, rows are built with `textContent` rather than markup strings, and a newer load supersedes an older one. +### Page visibility + +`core/visibility.js` gives each mounted page `ctx.visibility`: + +| Member | What it does | +|---|---| +| `whileVisible(start, stop)` | Runs `start()` when the page comes on screen (at once, if it mounts on screen) and `stop()` when it leaves. Returns a function that ends the registration, running `stop()` first if needed | +| `every(ms, fn)` | `fn()` at once, then every `ms` while on screen. The interval is cleared while hidden and restarted, with an immediate `fn()`, when the page is back. Returns the same kind of end function | +| `isVisible()` | True while the page is on screen | +| `tab` | The tab the page belongs to: its name, or `forPage(ctx, { tab })` | + +"On screen" means the page's tab is the active tab and the browser tab is +visible. Everything a page registered ends when its `ctx.signal` aborts, +after `destroy()`, so a swapped-out partial leaves no interval behind. + +The answer comes from `window.LEDVisibility` (`app-shell.js`), read at call +time, so the page modules and the classic partials that still call it +(Overview, Logs, Tools) agree on the active tab, and the SSE streams keep +pausing with them. Each registration takes its own `LEDVisibility` key, so +registrations never replace each other or a classic partial's. Without +`LEDVisibility` (a page outside `base.html`), the browser tab's visibility +alone decides. Moving the tracker itself into the module (the shell table +below) changes only `core/visibility.js`. + ### One facade `window.LEDMatrix` is the only global the module code adds: @@ -249,7 +281,7 @@ are the inline script in each partial today. | 5 | Backup & Restore | 232 lines, now 0 | **Done in stage 2.** Its 5 globals (`exportBackup`, `loadBackupList`, `validateRestoreFile`, `clearRestore`, `runRestore`) are deprecated aliases; the buttons are delegated `data-action`s. Uploads go through `ctx.api.request(..., { body: formData })` (`api.js` gained a raw `body` option) | | 6 | Schedule | 193 lines, now 0 | **Done in stage 3.** Its 2 `hx-on` response handlers (`handleScheduleResponse`, `handleDimScheduleResponse`) are one `htmx:afterRequest` listener on the page root, and deprecated aliases. The forms are marked `data-reports-result` so `app.js` does not repeat the server's message. The saved schedules reach the module as JSON in `data-schedule-config` / `data-dim-schedule-config` instead of being templated into the script | | 7 | General | 153 lines, now 0 | **Done in stage 3.** The Security section's three forms and two buttons are delegated `data-action`s (one submit and one click listener); `window.webLogin` is a deprecated alias of an object with its five methods. Login requests go through `ctx.api`, so the login redirect is quiet. The settings form keeps its `hx-on` call to the shared `showSaveResult`, as Rotation's does | -| 8 | Display | 231 | First page with `LEDVisibility` timers: those move to a `ctx.visibility` service that stops on destroy | +| 8 | Display | 292 lines (2 scripts), now 0 | **Done in stage 4.** The first page with a timer: the 5 s multi-display sync poll is `ctx.visibility.every(5000, ...)` (above), so it runs only while the tab is on screen and stops when the partial is swapped out. Its one global, `updateSyncUI` (the Role menu's `onchange`), is a deprecated alias; the Advanced section's `onclick` is a delegated `data-action="toggle-section"` that calls the shared `toggleSection`. The status poll and the scroll-speed hint go through `ctx.api` with `ctx.signal`, as does the Vegas order widget's plugin-list request. The settings form keeps its `hx-on` call to `showSaveResult` and its `onsubmit` call to `fixInvalidNumberInputs`, as Rotation's does | | 9 | Overview | 410 (4 scripts) | First-run surface: Getting Started, update banner, live preview. Five globals | | 10 | WiFi | 364 | `x-data="wifiSetup()"` is defined by its own script. Moves to `Alpine.data()` registered from the module. AP-mode first screen, so it needs the AP-mode test on a real device | | 11 | Fonts | 681 | Large, but self-contained (6 globals) | @@ -266,7 +298,7 @@ the order: | `showNotification` | 4 versions | `core/notify.js` | | The modal helper | `utils/dialog.js` | `core/dialog.js` | | SSE streams | `app-shell.js` | `core/streams.js` | -| `LEDVisibility` | `app-shell.js` | `core/visibility.js` | +| `LEDVisibility` | `app-shell.js` | `core/visibility.js` (the page-facing `ctx.visibility` is there since step 8; it reads the tracker from `app-shell.js`) | Each move leaves the old global as an alias. When the last inline script is gone, the script re-execution in `htmx-config.js` and the "HTMX never @@ -286,13 +318,15 @@ Unit suites need only node. They import the shipped modules directly: | Suite | Kind | What it covers | |---|---|---| -| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment | +| `unit/test_page_registry.js` | Unit, minimal DOM shim | The lifecycle: one init per root, destroy on swap, a veto keeps the page, swaps elsewhere leave it alone, the sweep, lazy loading, a destroy while loading, error containment, `mountContext` fields per mount | +| `dom/test_visibility_service.js` | DOM: real `LEDVisibility` from `app-shell.js`, real registry, no server | `whileVisible` and `every` start and stop with the active tab and the browser tab's visibility; no interval runs while hidden or after a swap-out; one interval after five swaps; registrations never replace each other or a classic partial's; a destroyed page registers nothing; a throwing `start()` is contained; the no-`LEDVisibility` fallback | | `unit/test_core_modules.js` | Unit | `api.js` (envelope, errors, abort, login redirect, path check) and `facade.js` (facade, aliases) | | `dom/test_cache_page.js` | DOM: real partial, real API shape | No inline script; one request per swap and per Refresh after five swaps; a cancelled request draws nothing; hostile keys stay text; delete, empty, error, network and login states | | `dom/test_durations_page.js` | DOM: real partial, real widget, real API shape | One plugin-list request per swap; Move down moves one place after five swaps; the swap cancels a request in flight; a late-loading widget is waited for, and a page swapped away while waiting starts nothing; hostile names stay text | | `dom/test_operation_history_page.js` | DOM: real partial, real API shape | One history request per swap and per Refresh; the plugin filter filled once (from `PluginAPI`'s cache when loaded); paging, filters, debounced search, Clear (one DELETE), error/network/login states, cancel on swap; hostile ids, users and errors stay text | | `dom/test_raw_json_page.js` | DOM: real partial, real config | One POST per Save after five swaps, to the right file; Format and Validate act once; invalid JSON never sent and its message stays text; a save survives a swap and is still reported; the old globals' entry points | | `dom/test_schedule_page.js` | DOM: real partial, real widget | Both pickers drawn once per swap from the saved config; after five swaps each form's answer is one notification (message, fallback, refused, non-JSON, `null`), a request from outside the forms none; the brightness label; a late widget waited for, a page swapped away while waiting draws nothing; the old globals' entry points | +| `dom/test_display_page.js` | DOM: real partial, real widget, real `LEDVisibility`, real API shape | After five swaps one page, one sync interval, the Vegas order drawn once and each control acting once (brightness, resolution, the two show/hide toggles, the Advanced toggle, one debounced hint request); the sync poll only while on screen and never after a swap-out; sync states and hostile peer names as text, failure and login answers; a late widget waited for; `updateSyncUI`'s entry point | | `dom/test_general_page.js` | DOM: real partial, real widget, real API shape | The timezone picker drawn once per swap with the saved zone; the settings form left to htmx; after five swaps each Security action makes one request (create, copy, revoke and its cancel, password and its mismatch); hostile token names stay text; refused, network and login answers; a create made before a swap is still reported and draws nothing; `webLogin`'s entry points | | `dom/test_backup_restore_page.js` | DOM: real partial, real API shape | One request per Refresh, Delete, Export (busy button ignores a second click), Inspect and Restore after five swaps; the upload's fields and the six restore options; reads cancelled by a swap, writes not; hostile file and host names stay text; the old globals' entry points | | `test/web_interface/test_es_modules.py` | pytest | MIME type; `no-cache` without `?v` and immutable with it; `boot.js` loads last; every import resolves inside `core/` and `pages/`; the converted pages are exactly the registered ones, each with its module, `init`, and one root in the rendered partial; a converted partial has no ` @@ -830,7 +795,7 @@ With this off a live game takes over the whole display with the full-screen scor
- @@ -879,261 +844,3 @@ With this off a live game takes over the whole display with the full-screen scor
- -