From e8b8fab3f876a264b714229b2352075cdf19edb2 Mon Sep 17 00:00:00 2001 From: mwisnowski <93788087+mwisnowski@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:54:51 -0700 Subject: [PATCH] feat: add hover-intent prefetch for Open Deck button (WEB_PREFETCH=1) (#68) --- .env.example | 1 + CHANGELOG.md | 3 +- DOCKER.md | 1 + RELEASE_NOTES_TEMPLATE.md | 4 +- code/web/app.py | 2 + code/web/routes/decks.py | 7 +- code/web/static/ts/prefetch-hover.ts | 118 +++++++++++++++++++++++++++ code/web/templates/base.html | 3 + code/web/templates/decks/index.html | 2 +- docker-compose.yml | 1 + dockerhub-docker-compose.yml | 1 + 11 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 code/web/static/ts/prefetch-hover.ts diff --git a/.env.example b/.env.example index d4e5179..54b1454 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,7 @@ WEB_STAGE_ORDER=new # new|legacy. 'new' (default): creatures → # Ideals UI Mode WEB_IDEALS_UI=slider # input|slider. 'slider' (default): range sliders with live value display. 'input': text input boxes +WEB_PREFETCH=0 # 1=enable hover-intent prefetch on key nav targets (e.g. Open Deck); respects Data Saver # Tagging Refinement Feature Flags TAG_NORMALIZE_KEYWORDS=1 # dockerhub: TAG_NORMALIZE_KEYWORDS="1" # Normalize keywords & filter specialty mechanics diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a50ec4..347c9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Link PRs/issues inline when helpful, e.g., (#123) or [#123]. Reference-style links at the bottom are encouraged for readability. ## [Unreleased] -_No unreleased changes yet._ +### Added +- **Hover-intent prefetch** (`WEB_PREFETCH=1`): Hovering over an "Open" button on the Finished Decks page now prefetches the deck view in the background after a 100 ms delay, eliminating the CSV-parse wait on click. On Chrome 108+, uses the Speculation Rules API for full prerender (`data-prerender-ok="1"`); falls back to `rel=prefetch` on other browsers. Feature-flagged and off by default; respects Data Saver / 2G connections and limits concurrent prefetches to 2. ## [4.5.1] - 2026-04-01 ### Added diff --git a/DOCKER.md b/DOCKER.md index c89668d..9775890 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -300,6 +300,7 @@ See `.env.example` for the full catalog. Common knobs: | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | | `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | | `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | +| `WEB_PREFETCH` | `0` | Enable hover-intent prefetch on key navigation targets (e.g. the Open button on Finished Decks). Requires `1` to activate; respects Data Saver / slow connections. | | `ENABLE_CARD_DETAILS` | `0` | Show card detail pages with similar card recommendations at `/cards/`. | | `SIMILARITY_CACHE_ENABLED` | `1` | Use pre-computed similarity cache for fast card detail pages. | | `ENABLE_BATCH_BUILD` | `1` | Enable Build X and Compare feature (build multiple decks in parallel and compare results). | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 7044a78..ad4576b 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -1,4 +1,6 @@ # MTG Python Deckbuilder ## [Unreleased] -_No unreleased changes yet._ +### Added +- **Hover-intent prefetch** (`WEB_PREFETCH=1`): Hovering over an "Open" button on the Finished Decks page now prefetches the deck view in the background after a 100 ms delay, eliminating the CSV-parse wait on click. On Chrome 108+, uses the Speculation Rules API for full prerender (`data-prerender-ok="1"`); falls back to `rel=prefetch` on other browsers. Feature-flagged and off by default; respects Data Saver / 2G connections and limits concurrent prefetches to 2. + diff --git a/code/web/app.py b/code/web/app.py index 72b1156..805b113 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -234,6 +234,7 @@ RATE_LIMIT_SUGGEST = _as_int(os.getenv("RANDOM_RATE_LIMIT_SUGGEST"), 30) RANDOM_STRUCTURED_LOGS = _as_bool(os.getenv("RANDOM_STRUCTURED_LOGS"), False) RANDOM_REROLL_THROTTLE_MS = _as_int(os.getenv("RANDOM_REROLL_THROTTLE_MS"), 350) USER_THEME_LIMIT = _as_int(os.getenv("USER_THEME_LIMIT"), 8) +ENABLE_PREFETCH = _as_bool(os.getenv("WEB_PREFETCH"), False) _THEME_MODE_ENV = (os.getenv("THEME_MATCH_MODE") or "").strip().lower() DEFAULT_THEME_MATCH_MODE = "strict" if _THEME_MODE_ENV in {"strict", "s"} else "permissive" @@ -364,6 +365,7 @@ templates.env.globals.update({ "theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS, "user_theme_limit": USER_THEME_LIMIT, "default_theme_match_mode": DEFAULT_THEME_MATCH_MODE, + "prefetch_enabled": ENABLE_PREFETCH, }) # Expose catalog hash (for cache versioning / service worker) – best-effort, fallback to 'dev' diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index e6f5c32..cec9523 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple from ..app import templates from ..services.orchestrator import tags_for_commander from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx -from ..app import ENABLE_BUDGET_MODE +from ..app import ENABLE_BUDGET_MODE, ENABLE_PREFETCH router = APIRouter(prefix="/decks") @@ -445,7 +445,10 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: except Exception: pass - return templates.TemplateResponse("decks/view.html", ctx) + resp = templates.TemplateResponse("decks/view.html", ctx) + if ENABLE_PREFETCH: + resp.headers["Cache-Control"] = "private, max-age=30, must-revalidate" + return resp @router.get("/compare", response_class=HTMLResponse) diff --git a/code/web/static/ts/prefetch-hover.ts b/code/web/static/ts/prefetch-hover.ts new file mode 100644 index 0000000..c2d754d --- /dev/null +++ b/code/web/static/ts/prefetch-hover.ts @@ -0,0 +1,118 @@ +/** + * prefetch-hover.ts — Hover-intent prefetch/prerender for key navigation targets. + * + * Enabled server-side via WEB_PREFETCH=1. Elements opt in with: + * data-prefetch="1" — enable prefetch on this element + * data-prefetch-url="" — URL to prefetch (falls back to el.href) + * data-prerender-ok="1" — allow Chrome Speculation Rules prerender + * (only safe GET routes with no side effects) + * + * Strategy selection (per element): + * data-prerender-ok="1" + Chrome Speculation Rules support → prerender + * otherwise → rel=prefetch + * + * Progressive enhancement: degrades gracefully when unsupported. + * Respects navigator.connection.saveData and slow (2G) effective connections. + */ +(function () { + 'use strict'; + + const MAX_CONCURRENT = 2; + const MAX_PRERENDERS = 2; + const DELAY_MS = 100; + let _inflight = 0; + const _prefetched: Record = {}; + + // Speculation Rules API detection (Chrome 108+) + const _supportsSpeculation: boolean = (function () { + try { + return typeof HTMLScriptElement !== 'undefined' && + 'supports' in HTMLScriptElement && + typeof (HTMLScriptElement as any).supports === 'function' && + (HTMLScriptElement as any).supports('speculationrules'); + } catch (_) { return false; } + })(); + + let _speculationEl: HTMLScriptElement | null = null; + const _prerenderQueued: string[] = []; + + function _saverMode(): boolean { + try { + const conn: any = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection || {}; + if (conn.saveData === true) return true; + const et: string = conn.effectiveType || ''; + return et === '2g' || et === 'slow-2g'; + } catch (_) { return false; } + } + + /** Inject/update a single + {% endif %}