mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-04 12:17:17 +02:00
feat: add hover-intent prefetch for Open Deck button (WEB_PREFETCH=1) (#68)
This commit is contained in:
parent
1f01d8b493
commit
e8b8fab3f8
11 changed files with 138 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<name>`. |
|
||||
| `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). |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
118
code/web/static/ts/prefetch-hover.ts
Normal file
118
code/web/static/ts/prefetch-hover.ts
Normal file
|
|
@ -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>" — 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<string, boolean> = {};
|
||||
|
||||
// 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 <script type="speculationrules"> for prerender. */
|
||||
function _addSpeculationPrerender(url: string): void {
|
||||
if (_prerenderQueued.indexOf(url) !== -1) return;
|
||||
// Cap queued prerenders to avoid excess memory
|
||||
if (_prerenderQueued.length >= MAX_PRERENDERS) {
|
||||
_prerenderQueued.shift();
|
||||
}
|
||||
_prerenderQueued.push(url);
|
||||
const rules = { prerender: [{ source: 'list', urls: _prerenderQueued.slice(), eagerness: 'immediate' }] };
|
||||
if (!_speculationEl) {
|
||||
_speculationEl = document.createElement('script');
|
||||
_speculationEl.type = 'speculationrules';
|
||||
document.head.appendChild(_speculationEl);
|
||||
}
|
||||
_speculationEl.textContent = JSON.stringify(rules);
|
||||
}
|
||||
|
||||
function _injectPrefetch(url: string): void {
|
||||
if (_prefetched[url]) return;
|
||||
if (_inflight >= MAX_CONCURRENT) return;
|
||||
_prefetched[url] = true;
|
||||
_inflight++;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'prefetch';
|
||||
link.href = url;
|
||||
link.as = 'document';
|
||||
link.addEventListener('load', function () { if (_inflight > 0) _inflight--; });
|
||||
link.addEventListener('error', function () { if (_inflight > 0) _inflight--; });
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
const _wired: WeakSet<Element> = new WeakSet();
|
||||
|
||||
function _attach(el: Element): void {
|
||||
if (_wired.has(el)) return;
|
||||
_wired.add(el);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
el.addEventListener('mouseenter', function () {
|
||||
if (_saverMode()) return;
|
||||
const url = (el as HTMLElement).getAttribute('data-prefetch-url') || (el as HTMLAnchorElement).href || '';
|
||||
if (!url) return;
|
||||
const prerenderOk = (el as HTMLElement).getAttribute('data-prerender-ok') === '1';
|
||||
timer = setTimeout(function () {
|
||||
timer = null;
|
||||
if (_saverMode()) return;
|
||||
if (_supportsSpeculation && prerenderOk) {
|
||||
_addSpeculationPrerender(url);
|
||||
} else {
|
||||
if (_inflight >= MAX_CONCURRENT) return;
|
||||
_injectPrefetch(url);
|
||||
}
|
||||
}, DELAY_MS);
|
||||
});
|
||||
el.addEventListener('mouseleave', function () {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function _init(): void {
|
||||
document.querySelectorAll('[data-prefetch="1"]').forEach(_attach);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
|
||||
// Re-scan after HTMX partial updates so dynamically-added elements are wired
|
||||
document.addEventListener('htmx:afterSettle', _init);
|
||||
})();
|
||||
|
|
@ -399,6 +399,9 @@
|
|||
{% endif %}
|
||||
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
|
||||
<!-- Hover card panel system moved to cardHover.ts -->
|
||||
{% if prefetch_enabled %}
|
||||
<script src="/static/js/prefetch-hover.js?v=20260401-1"></script>
|
||||
{% endif %}
|
||||
<!-- Price tooltip: lightweight fetch on mouseenter for .card-name-price-hover -->
|
||||
<script>
|
||||
(function(){
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
{% endif %}
|
||||
<form action="/decks/view" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit" aria-label="Open deck {{ it.commander }}">Open</button>
|
||||
<button type="submit" aria-label="Open deck {{ it.commander }}"{% if prefetch_enabled %} data-prefetch="1" data-prefetch-url="/decks/view?name={{ it.name|urlencode }}"{% endif %}>Open</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ services:
|
|||
|
||||
# Ideals UI Mode
|
||||
WEB_IDEALS_UI: "slider" # input|slider. 'slider' (default): range sliders. 'input': text boxes
|
||||
WEB_PREFETCH: "1" # 1=enable hover-intent prefetch on key navigation targets (e.g. Open Deck)
|
||||
|
||||
# Tagging Refinement Feature Flags
|
||||
TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended)
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ services:
|
|||
|
||||
# Ideals UI Mode
|
||||
WEB_IDEALS_UI: "slider" # input|slider. 'slider' (default): range sliders. 'input': text boxes
|
||||
WEB_PREFETCH: "0" # 1=enable hover-intent prefetch on key navigation targets (e.g. Open Deck)
|
||||
|
||||
# Tagging Refinement Feature Flags
|
||||
TAG_NORMALIZE_KEYWORDS: "1" # 1=normalize keywords & filter specialty mechanics (recommended)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue