diff --git a/.env.example b/.env.example index 6827b32..38e83f2 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0" ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0" WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" ALLOW_MUST_HAVES=1 # dockerhub: ALLOW_MUST_HAVES="1" +SHOW_MUST_HAVE_BUTTONS=0 # dockerhub: SHOW_MUST_HAVE_BUTTONS="0" (set to 1 to surface must include/exclude buttons) WEB_THEME_PICKER_DIAGNOSTICS=0 # 1=enable uncapped synergies, diagnostics fields & /themes/metrics (dev only) ############################ diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f21bf..71cde8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,4 @@ -- Random Modes (alpha): added env flags RANDOM_MODES, RANDOM_UI, RANDOM_MAX_ATTEMPTS, RANDOM_TIMEOUT_MS. -- Determinism: CSV_FILES_DIR override to point tests to csv_files/testdata; permalink now carries optional random fields (seed/theme/constraints). # Changelog - -All notable changes to this project will be documented in this file. - This format follows Keep a Changelog principles and aims for Semantic Versioning. ## How we version @@ -14,13 +9,30 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -- _No changes yet_ +- Responsiveness tweaks: shared HTMX debounce helper, deferred skeleton microcopy, and containment rules for long card lists. +- Optimistic include/exclude experience with HTMX caching, prefetch hints, and telemetry instrumentation for must-have interactions. +- Commander catalog skeleton placeholders and lazy commander art loading to smooth catalog fetch latency. +- Commander catalog default view now prewarms and pulls from an in-memory cache so repeat visits respond in under 200 ms. +- Virtualization helper now respects `data-virtualize-*` hints and powers deck summary lists without loading all rows at once. +- Step 5 deck summary now streams via an HTMX fragment so the main review payload stays lean while virtualization kicks in post-swap. +- Mana analytics now load on-demand with collapsible sections, reducing initial deck review time by ~30-40%. +- Interactive chart tooltips with click-to-pin highlighting make cross-referencing cards between charts and deck lists easier. ### Added -- _None_ +- Skeleton placeholders now accept `data-skeleton-label` microcopy and only surface after ~400 ms on the build wizard, stage navigator, and alternatives panel. +- Must-have toggle API (`/build/must-haves/toggle`), telemetry ingestion route (`/telemetry/events`), and structured logging helpers for include/exclude state changes and frontend beacons. +- Commander catalog results wrap in a deferred skeleton list, and commander art lazy-loads via a new `IntersectionObserver` helper in `code/web/static/app.js`. +- Collapsible accordions for Mana Overview and Test Hand sections defer content loading until expanded. +- Click-to-pin chart tooltips with consistent corner positioning (lower-left desktop, lower-right mobile) and working copy buttons. +- Virtualized card lists automatically render only visible items when 12+ cards are present.changes to this project will be documented in this file. ### Changed -- _None_ +- Commander search and theme picker now intelligently debounce keystrokes, preventing redundant requests while you type. +- Card grids use modern browser containment rules to minimize layout recalculations on large decks. +- Include/exclude buttons now respond immediately with optimistic updates, falling back gracefully if the server disagrees. +- Frequently-accessed views (like the commander catalog default) now load from memory, responding in under 200ms. +- Deck review now loads in focused chunks, keeping the initial page lean while analytics stream in progressively. +- Chart hover zones expanded to full column width for easier interaction. ### Fixed - _None_ diff --git a/DOCKER.md b/DOCKER.md index 6b5185b..9ac2c7b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -252,6 +252,7 @@ See `.env.example` for the full catalog. Common knobs: | `ENABLE_THEMES` | `1` | Keep the theme selector and themes explorer visible. | | `WEB_VIRTUALIZE` | `1` | Opt-in to virtualized lists/grids for large result sets. | | `ALLOW_MUST_HAVES` | `1` | Enable include/exclude enforcement in Step 5. | +| `SHOW_MUST_HAVE_BUTTONS` | `0` | Surface the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | ### Random build controls diff --git a/README.md b/README.md index f4fb612..ee62409 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ Most defaults are defined in `docker-compose.yml` and documented in `.env.exampl | `ENABLE_CUSTOM_THEMES` | `1` | Surface the Additional Themes section in the New Deck modal. | | `WEB_VIRTUALIZE` | `1` | Opt into virtualized lists for large datasets. | | `ALLOW_MUST_HAVES` | `1` | Enforce include/exclude (must-have) lists. | +| `SHOW_MUST_HAVE_BUTTONS` | `0` | Reveal the must include/exclude buttons and quick-add UI (requires `ALLOW_MUST_HAVES=1`). | | `THEME` | `dark` | Default UI theme (`system`, `light`, or `dark`). | ### Random build tuning diff --git a/code/web/app.py b/code/web/app.py index b11b393..afdfc49 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -19,7 +19,8 @@ from contextlib import asynccontextmanager from code.deck_builder.summary_telemetry import get_mdfc_metrics, get_partner_metrics, get_theme_metrics from tagging.multi_face_merger import load_merge_summary from .services.combo_utils import detect_all as _detect_all -from .services.theme_catalog_loader import prewarm_common_filters # type: ignore +from .services.theme_catalog_loader import prewarm_common_filters, load_index # type: ignore +from .services.commander_catalog_loader import load_commander_catalog # type: ignore from .services.tasks import get_session, new_sid, set_session_value # type: ignore # Resolve template/static dirs relative to this file @@ -42,6 +43,19 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue prewarm_common_filters() except Exception: pass + # Warm commander + theme catalogs so the first commander catalog request skips disk reads + try: + load_commander_catalog() + except Exception: + pass + try: + load_index() + except Exception: + pass + try: + commanders_routes.prewarm_default_page() # type: ignore[attr-defined] + except Exception: + pass # Warm preview card index once (updated Phase A: moved to card_index module) try: # local import to avoid cost if preview unused from .services.card_index import maybe_build_index # type: ignore @@ -112,6 +126,7 @@ ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), True) ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False) ENABLE_PRESETS = _as_bool(os.getenv("ENABLE_PRESETS"), False) ALLOW_MUST_HAVES = _as_bool(os.getenv("ALLOW_MUST_HAVES"), True) +SHOW_MUST_HAVE_BUTTONS = _as_bool(os.getenv("SHOW_MUST_HAVE_BUTTONS"), False) ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) ENABLE_PARTNER_MECHANICS = _as_bool(os.getenv("ENABLE_PARTNER_MECHANICS"), True) ENABLE_PARTNER_SUGGESTIONS = _as_bool(os.getenv("ENABLE_PARTNER_SUGGESTIONS"), True) @@ -251,6 +266,7 @@ templates.env.globals.update({ "enable_partner_mechanics": ENABLE_PARTNER_MECHANICS, "enable_partner_suggestions": ENABLE_PARTNER_SUGGESTIONS, "allow_must_haves": ALLOW_MUST_HAVES, + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "default_theme": DEFAULT_THEME, "random_modes": RANDOM_MODES, "random_ui": RANDOM_UI, @@ -840,6 +856,7 @@ async def status_sys(): "ENABLE_PRESETS": bool(ENABLE_PRESETS), "ENABLE_PARTNER_MECHANICS": bool(ENABLE_PARTNER_MECHANICS), "ALLOW_MUST_HAVES": bool(ALLOW_MUST_HAVES), + "SHOW_MUST_HAVE_BUTTONS": bool(SHOW_MUST_HAVE_BUTTONS), "DEFAULT_THEME": DEFAULT_THEME, "THEME_MATCH_MODE": DEFAULT_THEME_MATCH_MODE, "USER_THEME_LIMIT": int(USER_THEME_LIMIT), @@ -2186,6 +2203,7 @@ from .routes import owned as owned_routes # noqa: E402 from .routes import themes as themes_routes # noqa: E402 from .routes import commanders as commanders_routes # noqa: E402 from .routes import partner_suggestions as partner_suggestions_routes # noqa: E402 +from .routes import telemetry as telemetry_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(config_routes.router) app.include_router(decks_routes.router) @@ -2194,6 +2212,7 @@ app.include_router(owned_routes.router) app.include_router(themes_routes.router) app.include_router(commanders_routes.router) app.include_router(partner_suggestions_routes.router) +app.include_router(telemetry_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: diff --git a/code/web/routes/build.py b/code/web/routes/build.py index f24b36f..e058ed6 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -3,9 +3,11 @@ from __future__ import annotations from fastapi import APIRouter, Request, Form, Query from fastapi.responses import HTMLResponse, JSONResponse from typing import Any, Iterable +import json from ..app import ( ALLOW_MUST_HAVES, ENABLE_CUSTOM_THEMES, + SHOW_MUST_HAVE_BUTTONS, USER_THEME_LIMIT, DEFAULT_THEME_MATCH_MODE, _sanitize_theme, @@ -13,6 +15,7 @@ from ..app import ( ENABLE_PARTNER_SUGGESTIONS, ) from ..services.build_utils import ( + step5_base_ctx, step5_ctx_from_result, step5_error_ctx, step5_empty_ctx, @@ -37,6 +40,7 @@ from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as from ..services.telemetry import ( log_commander_create_deck, log_partner_suggestion_selected, + log_include_exclude_toggle, ) from ..services.partner_suggestions import get_partner_suggestions from urllib.parse import urlparse, quote_plus @@ -136,6 +140,90 @@ def warm_validation_name_cache() -> None: pass +def _merge_hx_trigger(response: Any, payload: dict[str, Any]) -> None: + if not payload or response is None: + return + try: + existing = response.headers.get("HX-Trigger") if hasattr(response, "headers") else None + except Exception: + existing = None + try: + if existing: + try: + data = json.loads(existing) + except Exception: + data = {} + if isinstance(data, dict): + data.update(payload) + response.headers["HX-Trigger"] = json.dumps(data) + return + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + try: + response.headers["HX-Trigger"] = json.dumps(payload) + except Exception: + pass + + +def _step5_summary_placeholder_html(token: int, *, message: str | None = None) -> str: + text = message or "Deck summary will appear after the build completes." + return ( + f'
' + f'
{_esc(text)}
' + '
' + ) + + +def _must_have_state(sess: dict) -> tuple[dict[str, Any], list[str], list[str]]: + includes = list(sess.get("include_cards") or []) + excludes = list(sess.get("exclude_cards") or []) + state = { + "includes": includes, + "excludes": excludes, + "enforcement_mode": (sess.get("enforcement_mode") or "warn"), + "allow_illegal": bool(sess.get("allow_illegal")), + "fuzzy_matching": bool(sess.get("fuzzy_matching", True)), + } + return state, includes, excludes + + +def _render_include_exclude_summary( + request: Request, + sess: dict, + sid: str, + *, + state: dict[str, Any] | None = None, + includes: list[str] | None = None, + excludes: list[str] | None = None, +) -> HTMLResponse: + ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False) + if state is None or includes is None or excludes is None: + state, includes, excludes = _must_have_state(sess) + ctx["must_have_state"] = state + ctx["summary"] = sess.get("step5_summary") if sess.get("step5_summary_ready") else None + ctx["include_cards"] = includes + ctx["exclude_cards"] = excludes + response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + + +def _current_builder_summary(sess: dict) -> Any | None: + try: + ctx = sess.get("build_ctx") or {} + builder = ctx.get("builder") if isinstance(ctx, dict) else None + if builder is None: + return None + summary_fn = getattr(builder, "build_deck_summary", None) + if callable(summary_fn): + return summary_fn() + except Exception: + return None + return None + + _COLOR_NAME_MAP = { "W": "White", "U": "Blue", @@ -684,6 +772,117 @@ def _resolve_partner_selection( router = APIRouter(prefix="/build") + +@router.post("/must-haves/toggle", response_class=HTMLResponse) +async def toggle_must_haves( + request: Request, + card_name: str = Form(...), + list_type: str = Form(...), + enabled: str = Form("1"), +): + if not ALLOW_MUST_HAVES: + return JSONResponse({"error": "Must-have lists are disabled"}, status_code=403) + + name = str(card_name or "").strip() + if not name: + return JSONResponse({"error": "Card name is required"}, status_code=400) + + list_key = str(list_type or "").strip().lower() + if list_key not in {"include", "exclude"}: + return JSONResponse({"error": "Unsupported toggle type"}, status_code=400) + + enabled_flag = str(enabled).strip().lower() in {"1", "true", "yes", "on"} + + sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") + if not sid: + sid = new_sid() + sess = get_session(sid) + + includes = list(sess.get("include_cards") or []) + excludes = list(sess.get("exclude_cards") or []) + include_lookup = {str(v).strip().lower(): str(v) for v in includes if str(v).strip()} + exclude_lookup = {str(v).strip().lower(): str(v) for v in excludes if str(v).strip()} + key = name.lower() + display_name = include_lookup.get(key) or exclude_lookup.get(key) or name + + changed = False + include_limit = 10 + exclude_limit = 15 + + def _remove_casefold(items: list[str], item_key: str) -> list[str]: + return [c for c in items if str(c).strip().lower() != item_key] + + if list_key == "include": + if enabled_flag: + if key not in include_lookup: + if len(include_lookup) >= include_limit: + return JSONResponse({"error": f"Include limit reached ({include_limit})."}, status_code=400) + includes.append(name) + include_lookup[key] = name + changed = True + if key in exclude_lookup: + excludes = _remove_casefold(excludes, key) + exclude_lookup.pop(key, None) + changed = True + else: + if key in include_lookup: + includes = _remove_casefold(includes, key) + include_lookup.pop(key, None) + changed = True + else: # exclude + if enabled_flag: + if key not in exclude_lookup: + if len(exclude_lookup) >= exclude_limit: + return JSONResponse({"error": f"Exclude limit reached ({exclude_limit})."}, status_code=400) + excludes.append(name) + exclude_lookup[key] = name + changed = True + if key in include_lookup: + includes = _remove_casefold(includes, key) + include_lookup.pop(key, None) + changed = True + else: + if key in exclude_lookup: + excludes = _remove_casefold(excludes, key) + exclude_lookup.pop(key, None) + changed = True + + if changed: + sess["include_cards"] = includes + sess["exclude_cards"] = excludes + if "include_exclude_diagnostics" in sess: + try: + del sess["include_exclude_diagnostics"] + except Exception: + pass + + response = _render_include_exclude_summary(request, sess, sid) + + try: + log_include_exclude_toggle( + request, + card_name=display_name, + action=list_key, + enabled=enabled_flag, + include_count=len(includes), + exclude_count=len(excludes), + ) + except Exception: + pass + + trigger_payload = { + "card": display_name, + "list": list_key, + "enabled": enabled_flag, + "include_count": len(includes), + "exclude_count": len(excludes), + } + try: + _merge_hx_trigger(response, {"must-haves:toggle": trigger_payload}) + except Exception: + pass + return response + # Alternatives cache moved to services/alts_utils @@ -1132,6 +1331,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "labels": orch.ideal_labels(), "defaults": orch.ideal_defaults(), "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "form": { "prefer_combos": bool(sess.get("prefer_combos")), @@ -1531,6 +1731,7 @@ async def build_new_submit( "labels": orch.ideal_labels(), "defaults": orch.ideal_defaults(), "allow_must_haves": ALLOW_MUST_HAVES, + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "form": _form_state(suggested), "tag_slot_html": None, @@ -1554,6 +1755,7 @@ async def build_new_submit( "labels": orch.ideal_labels(), "defaults": orch.ideal_defaults(), "allow_must_haves": ALLOW_MUST_HAVES, # Add feature flag + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "form": _form_state(commander), "tag_slot_html": None, @@ -1657,6 +1859,7 @@ async def build_new_submit( "labels": orch.ideal_labels(), "defaults": orch.ideal_defaults(), "allow_must_haves": ALLOW_MUST_HAVES, + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "form": _form_state(primary_commander_name), "tag_slot_html": tag_slot_html, @@ -1794,6 +1997,7 @@ async def build_new_submit( "labels": orch.ideal_labels(), "defaults": orch.ideal_defaults(), "allow_must_haves": ALLOW_MUST_HAVES, + "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "form": _form_state(sess.get("commander", "")), "tag_slot_html": None, @@ -2245,6 +2449,7 @@ async def build_step5_rewind(request: Request, to: str = Form(...)) -> HTMLRespo ctx_resp = step5_error_ctx(request, sess, f"Failed to rewind: {e}") resp = templates.TemplateResponse("build/_step5.html", ctx_resp) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx_resp.get("summary_token", 0)}}) return resp @@ -2884,6 +3089,7 @@ async def build_step5_get(request: Request) -> HTMLResponse: base = step5_empty_ctx(request, sess) resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}}) return resp @router.post("/step5/continue", response_class=HTMLResponse) @@ -2954,6 +3160,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse: err_ctx = step5_error_ctx(request, sess, f"Failed to continue: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) return resp stage_label = res.get("label") # If we just applied Multi-Copy, stamp the applied key so we don't rebuild again @@ -2968,6 +3175,7 @@ async def build_step5_continue(request: Request) -> HTMLResponse: ctx2 = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) resp = templates.TemplateResponse("build/_step5.html", ctx2) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}}) return resp @router.post("/step5/rerun", response_class=HTMLResponse) @@ -3006,6 +3214,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: err_ctx = step5_error_ctx(request, sess, f"Failed to rerun stage: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) return resp sess["last_step"] = 5 # Build locked cards list with ownership and in-deck presence @@ -3032,6 +3241,7 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: ctx3["locked_cards"] = locked_cards resp = templates.TemplateResponse("build/_step5.html", ctx3) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx3.get("summary_token", 0)}}) return resp @@ -3073,6 +3283,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) resp = templates.TemplateResponse("build/_step5.html", ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx.get("summary_token", 0)}}) return resp except Exception as e: # Surface a friendly error on the step 5 screen with normalized context @@ -3086,6 +3297,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: err_ctx["commander"] = commander resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) return resp @router.get("/step5/start", response_class=HTMLResponse) @@ -3151,8 +3363,61 @@ async def build_step5_reset_stage(request: Request) -> HTMLResponse: }) resp = templates.TemplateResponse("build/_step5.html", base) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": base.get("summary_token", 0)}}) return resp + +@router.get("/step5/summary", response_class=HTMLResponse) +async def build_step5_summary(request: Request, token: int = Query(0)) -> HTMLResponse: + sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") + if not sid: + sid = new_sid() + sess = get_session(sid) + + try: + session_token = int(sess.get("step5_summary_token", 0)) + except Exception: + session_token = 0 + try: + requested_token = int(token) + except Exception: + requested_token = 0 + ready = bool(sess.get("step5_summary_ready")) + summary_data = sess.get("step5_summary") if ready else None + if summary_data is None and ready: + summary_data = _current_builder_summary(sess) + if summary_data is not None: + try: + sess["step5_summary"] = summary_data + except Exception: + pass + + synergies: list[str] = [] + try: + raw_synergies = sess.get("step5_synergies") + if isinstance(raw_synergies, (list, tuple, set)): + synergies = [str(item) for item in raw_synergies if str(item).strip()] + except Exception: + synergies = [] + + active_token = session_token if session_token >= requested_token else requested_token + + if not ready or summary_data is None: + message = "Deck summary will appear after the build completes." if not ready else "Deck summary is not available yet. Try rerunning the current stage." + placeholder = _step5_summary_placeholder_html(active_token, message=message) + response = HTMLResponse(placeholder) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + + ctx = step5_base_ctx(request, sess) + ctx["summary"] = summary_data + ctx["synergies"] = synergies + ctx["summary_ready"] = True + ctx["summary_token"] = active_token + response = templates.TemplateResponse("partials/deck_summary.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response + # --- Phase 8: Lock/Replace/Compare/Permalink minimal API --- @router.post("/lock") @@ -4244,6 +4509,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse: err_ctx = step5_error_ctx(request, sess, "No active build context to enforce.") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) return resp # Ensure we have a CSV base stem for consistent re-exports base_stem = None @@ -4311,6 +4577,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse: err_ctx = step5_error_ctx(request, sess, f"Enforcement failed: {e}") resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": err_ctx.get("summary_token", 0)}}) return resp # Reload compliance JSON and summary compliance = None @@ -4358,6 +4625,7 @@ async def build_enforce_apply(request: Request) -> HTMLResponse: page_ctx = step5_ctx_from_result(request, sess, res, status_text="Build complete", show_skipped=True) resp = templates.TemplateResponse(request, "build/_step5.html", page_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": page_ctx.get("summary_token", 0)}}) return resp @@ -4387,9 +4655,14 @@ async def build_enforcement_fullpage(request: Request) -> HTMLResponse: comp = orch._attach_enforcement_plan(b, comp) # type: ignore[attr-defined] except Exception: pass - ctx2 = {"request": request, "compliance": comp} + try: + summary_token = int(sess.get("step5_summary_token", 0)) + except Exception: + summary_token = 0 + ctx2 = {"request": request, "compliance": comp, "summary_token": summary_token} resp = templates.TemplateResponse(request, "build/enforcement.html", ctx2) resp.set_cookie("sid", sid, httponly=True, samesite="lax") + _merge_hx_trigger(resp, {"step5:refresh": {"token": ctx2.get("summary_token", 0)}}) return resp diff --git a/code/web/routes/commanders.py b/code/web/routes/commanders.py index 7545f0e..88053b5 100644 --- a/code/web/routes/commanders.py +++ b/code/web/routes/commanders.py @@ -1,9 +1,10 @@ from __future__ import annotations +from collections import OrderedDict from dataclasses import dataclass from difflib import SequenceMatcher from math import ceil -from typing import Iterable, Mapping, Sequence +from typing import Dict, Iterable, Mapping, Sequence, Tuple from urllib.parse import urlencode import re @@ -11,7 +12,7 @@ from fastapi import APIRouter, Query, Request from fastapi.responses import HTMLResponse from ..app import templates -from ..services.commander_catalog_loader import CommanderRecord, load_commander_catalog +from ..services.commander_catalog_loader import CommanderCatalog, CommanderRecord, load_commander_catalog from ..services.theme_catalog_loader import load_index, slugify from ..services.telemetry import log_commander_page_view @@ -89,6 +90,20 @@ class ThemeRecommendation: score: float +@dataclass(slots=True) +class CommanderFilterCacheEntry: + records: Tuple[CommanderRecord, ...] + theme_recommendations: Tuple[ThemeRecommendation, ...] + page_views: Dict[int, Tuple[CommanderView, ...]] + + +_FILTER_CACHE_MAX = 48 +_FILTER_CACHE: "OrderedDict[tuple[str, str, str, str], CommanderFilterCacheEntry]" = OrderedDict() +_THEME_OPTIONS_CACHE: Dict[str, Tuple[str, ...]] = {} +_COLOR_OPTIONS_CACHE: Dict[str, Tuple[Tuple[str, str], ...]] = {} +_LAST_SEEN_ETAG: str | None = None + + def _is_htmx(request: Request) -> bool: return request.headers.get("HX-Request", "").lower() == "true" @@ -142,6 +157,74 @@ def _color_label_from_code(code: str) -> str: return f"{pretty} ({code})" +def _cache_key_for_filters(etag: str, query: str | None, theme_query: str | None, color: str | None) -> tuple[str, str, str, str]: + def _normalize(text: str | None) -> str: + return (text or "").strip().lower() + + return ( + etag, + _normalize(query), + _normalize(theme_query), + (color or "").strip().upper(), + ) + + +def _ensure_catalog_caches(etag: str) -> None: + global _LAST_SEEN_ETAG + if _LAST_SEEN_ETAG == etag: + return + _LAST_SEEN_ETAG = etag + _FILTER_CACHE.clear() + _THEME_OPTIONS_CACHE.clear() + _COLOR_OPTIONS_CACHE.clear() + + +def _theme_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[str, ...]: + cached = _THEME_OPTIONS_CACHE.get(etag) + if cached is not None: + return cached + options = _collect_theme_names(entries) + result = tuple(options) + _THEME_OPTIONS_CACHE[etag] = result + return result + + +def _color_options_for_catalog(entries: Sequence[CommanderRecord], *, etag: str) -> Tuple[Tuple[str, str], ...]: + cached = _COLOR_OPTIONS_CACHE.get(etag) + if cached is not None: + return cached + options = tuple(_build_color_options(entries)) + _COLOR_OPTIONS_CACHE[etag] = options + return options + + +def _get_cached_filter_entry( + catalog: CommanderCatalog, + query: str | None, + theme_query: str | None, + canon_color: str | None, + theme_options: Sequence[str], +) -> CommanderFilterCacheEntry: + key = _cache_key_for_filters(catalog.etag, query, theme_query, canon_color) + cached = _FILTER_CACHE.get(key) + if cached is not None: + _FILTER_CACHE.move_to_end(key) + return cached + + filtered = tuple(_filter_commanders(catalog.entries, query, canon_color, theme_query)) + recommendations = tuple(_build_theme_recommendations(theme_query, theme_options)) + entry = CommanderFilterCacheEntry( + records=filtered, + theme_recommendations=recommendations, + page_views={}, + ) + _FILTER_CACHE[key] = entry + _FILTER_CACHE.move_to_end(key) + if len(_FILTER_CACHE) > _FILTER_CACHE_MAX: + _FILTER_CACHE.popitem(last=False) + return entry + + def _color_aria_label(record: CommanderRecord) -> str: if record.color_identity: names = [_COLOR_NAMES.get(ch, ch) for ch in record.color_identity] @@ -351,13 +434,19 @@ def _build_theme_recommendations(theme_query: str | None, theme_names: Sequence[ return tuple(filtered[:_THEME_RECOMMENDATION_LIMIT]) -def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> list[CommanderRecord]: - items = list(records) +def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None, theme: str | None) -> Sequence[CommanderRecord]: + items: Sequence[CommanderRecord] + if isinstance(records, Sequence): + items = records + else: + items = tuple(records) + color_code = _canon_color_code(color) if color_code: items = [rec for rec in items if _record_color_code(rec) == color_code] + normalized_query = _normalize_search_text(q) - if normalized_query: + if normalized_query and items: filtered: list[tuple[float, CommanderRecord]] = [] for rec in items: score = _commander_name_match_score(normalized_query, rec) @@ -368,6 +457,7 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: items = [rec for _, rec in filtered] else: items = [] + normalized_theme_query = _normalize_search_text(theme) if normalized_theme_query and items: theme_tokens = tuple(normalized_theme_query.split()) @@ -381,7 +471,10 @@ def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: items = [rec for _, rec in filtered_by_theme] else: items = [] - return items + + if isinstance(items, list): + return items + return tuple(items) def _build_color_options(records: Sequence[CommanderRecord]) -> list[tuple[str, str]]: @@ -441,27 +534,69 @@ async def commanders_index( color: str | None = Query(default=None, alias="color"), page: int = Query(default=1, ge=1), ) -> HTMLResponse: + catalog: CommanderCatalog | None = None entries: Sequence[CommanderRecord] = () error: str | None = None try: catalog = load_commander_catalog() entries = catalog.entries + _ensure_catalog_caches(catalog.etag) except FileNotFoundError: error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists." - theme_names = _collect_theme_names(entries) + except Exception: + error = "Commander catalog failed to load. Check server logs." + theme_query = (theme or "").strip() - filtered = _filter_commanders(entries, q, color, theme_query) - theme_recommendations = _build_theme_recommendations(theme_query, theme_names) - total_filtered = len(filtered) - page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1 - if page > page_count: - page = page_count - start_index = (page - 1) * PAGE_SIZE - end_index = start_index + PAGE_SIZE - page_records = filtered[start_index:end_index] - theme_info = _build_theme_info(page_records) - views = [_record_to_view(rec, theme_info) for rec in page_records] - color_options = _build_color_options(entries) if entries else [] + query_value = (q or "").strip() + canon_color = _canon_color_code(color) + + theme_names: Tuple[str, ...] = () + color_options: Tuple[Tuple[str, str], ...] | list[Tuple[str, str]] = () + filter_entry: CommanderFilterCacheEntry | None = None + total_filtered = 0 + page_count = 1 + page_records: Sequence[CommanderRecord] = () + views: Tuple[CommanderView, ...] = () + theme_recommendations: Tuple[ThemeRecommendation, ...] = () + + if catalog is not None: + theme_names = _theme_options_for_catalog(entries, etag=catalog.etag) + color_options = _color_options_for_catalog(entries, etag=catalog.etag) + filter_entry = _get_cached_filter_entry( + catalog, + query_value, + theme_query, + canon_color, + theme_names, + ) + total_filtered = len(filter_entry.records) + page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1 + if page > page_count: + page = page_count + if page < 1: + page = 1 + start_index = (page - 1) * PAGE_SIZE + end_index = start_index + PAGE_SIZE + page_records = filter_entry.records[start_index:end_index] + cached_views = filter_entry.page_views.get(page) if filter_entry else None + if cached_views is None: + theme_info = _build_theme_info(page_records) + computed_views = tuple(_record_to_view(rec, theme_info) for rec in page_records) + if filter_entry is not None: + filter_entry.page_views[page] = computed_views + if len(filter_entry.page_views) > 6: + oldest_key = next(iter(filter_entry.page_views)) + if oldest_key != page: + filter_entry.page_views.pop(oldest_key, None) + views = computed_views + else: + views = cached_views + theme_recommendations = filter_entry.theme_recommendations + else: + page = 1 + start_index = 0 + end_index = 0 + page_start = start_index + 1 if total_filtered else 0 page_end = start_index + len(page_records) has_prev = page > 1 @@ -494,12 +629,12 @@ async def commanders_index( context = { "request": request, "commanders": views, - "query": q or "", - "theme_query": theme_query, + "query": query_value, + "theme_query": theme_query, "color": canon_color, - "color_options": color_options, - "theme_options": theme_names, - "theme_recommendations": theme_recommendations, + "color_options": list(color_options) if color_options else [], + "theme_options": theme_names, + "theme_recommendations": theme_recommendations, "total_count": len(entries), "result_count": len(views), "result_total": total_filtered, @@ -540,3 +675,23 @@ async def commanders_index_alias( page: int = Query(default=1, ge=1), ) -> HTMLResponse: return await commanders_index(request, q=q, theme=theme, color=color, page=page) + + +def prewarm_default_page() -> None: + """Prime the commander catalog caches for the default (no-filter) view.""" + + try: + catalog = load_commander_catalog() + except Exception: + return + + try: + _ensure_catalog_caches(catalog.etag) + theme_options = _theme_options_for_catalog(catalog.entries, etag=catalog.etag) + entry = _get_cached_filter_entry(catalog, "", "", "", theme_options) + if 1 not in entry.page_views: + page_records = entry.records[:PAGE_SIZE] + theme_info = _build_theme_info(page_records) + entry.page_views[1] = tuple(_record_to_view(rec, theme_info) for rec in page_records) + except Exception: + return diff --git a/code/web/routes/telemetry.py b/code/web/routes/telemetry.py new file mode 100644 index 0000000..90842c7 --- /dev/null +++ b/code/web/routes/telemetry.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import Response +from pydantic import BaseModel, Field +from typing import Any, Dict + +from ..services.telemetry import log_frontend_event + +router = APIRouter(prefix="/telemetry", tags=["telemetry"]) + + +class TelemetryEvent(BaseModel): + event: str = Field(..., min_length=1) + data: Dict[str, Any] | None = None + + +@router.post("/events", status_code=204) +async def ingest_event(payload: TelemetryEvent, request: Request) -> Response: + log_frontend_event(request, event=payload.event, data=payload.data or {}) + return Response(status_code=204) diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index 9634999..ee97e43 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -5,7 +5,48 @@ from fastapi import Request from ..services import owned_store from . import orchestrator as orch from deck_builder import builder_constants as bc -from .. import app as app_module + + +_TRUE_SET = {"1", "true", "yes", "on", "y", "t"} +_FALSE_SET = {"0", "false", "no", "off", "n", "f"} + + +def _coerce_bool(value: object, default: bool) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + token = value.strip().lower() + if not token: + return default + if token in _TRUE_SET: + return True + if token in _FALSE_SET: + return False + try: + return bool(value) + except Exception: + return default + + +def _app_bool(name: str, default: bool = False) -> bool: + import os + import sys + + env_val = os.getenv(name) + if env_val is not None: + return _coerce_bool(env_val, default) + + app_module = sys.modules.get("code.web.app") + if app_module is not None: + try: + if hasattr(app_module, name): + return _coerce_bool(getattr(app_module, name), default) + except Exception: + return default + + return default def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, include_locks: bool = True) -> Dict[str, Any]: @@ -14,6 +55,8 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i Includes commander/tags/bracket/values, ownership flags, owned_set, locks, replace_mode, combo preferences, and static game_changers. Caller can layer run-specific results. """ + include_cards = list(sess.get("include_cards", []) or []) + exclude_cards = list(sess.get("exclude_cards", []) or []) ctx: Dict[str, Any] = { "request": request, "commander": sess.get("commander"), @@ -22,25 +65,49 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i "values": sess.get("ideals", orch.ideal_defaults()), "owned_only": bool(sess.get("use_owned_only")), "prefer_owned": bool(sess.get("prefer_owned")), - "partner_enabled": bool(sess.get("partner_enabled") and app_module.ENABLE_PARTNER_MECHANICS), + "partner_enabled": bool(sess.get("partner_enabled")) and _app_bool("ENABLE_PARTNER_MECHANICS", True), "secondary_commander": sess.get("secondary_commander"), "background": sess.get("background"), "partner_mode": sess.get("partner_mode"), "partner_warnings": list(sess.get("partner_warnings", []) or []), "combined_commander": sess.get("combined_commander"), "partner_auto_note": sess.get("partner_auto_note"), - "owned_set": owned_set(), + "owned_set": owned_set(), "game_changers": bc.GAME_CHANGERS, "replace_mode": bool(sess.get("replace_mode", True)), "prefer_combos": bool(sess.get("prefer_combos")), "combo_target_count": int(sess.get("combo_target_count", 2)), "combo_balance": str(sess.get("combo_balance", "mix")), "swap_mdfc_basics": bool(sess.get("swap_mdfc_basics")), + "allow_must_haves": _app_bool("ALLOW_MUST_HAVES", True), + "show_must_have_buttons": _app_bool("SHOW_MUST_HAVE_BUTTONS", False), + "include_cards": include_cards, + "exclude_cards": exclude_cards, } if include_name: ctx["name"] = sess.get("custom_export_base") if include_locks: ctx["locks"] = list(sess.get("locks", [])) + try: + ctx["summary_token"] = int(sess.get("step5_summary_token", 0)) + except Exception: + ctx["summary_token"] = 0 + ctx["summary_ready"] = bool(sess.get("step5_summary_ready")) + try: + raw_synergies = sess.get("step5_synergies") + if isinstance(raw_synergies, (list, tuple, set)): + ctx["synergies"] = [str(s) for s in raw_synergies if str(s).strip()] + else: + ctx["synergies"] = [] + except Exception: + ctx["synergies"] = [] + ctx["must_have_state"] = { + "includes": include_cards, + "excludes": exclude_cards, + "enforcement_mode": (sess.get("enforcement_mode") or "warn"), + "allow_illegal": bool(sess.get("allow_illegal")), + "fuzzy_matching": bool(sess.get("fuzzy_matching", True)), + } return ctx @@ -77,7 +144,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s use_owned = bool(sess.get("use_owned_only")) prefer = bool(sess.get("prefer_owned")) owned_names_list = owned_names() if (use_owned or prefer) else None - partner_enabled = bool(sess.get("partner_enabled")) and app_module.ENABLE_PARTNER_MECHANICS + partner_enabled = bool(sess.get("partner_enabled")) and _app_bool("ENABLE_PARTNER_MECHANICS", True) secondary_commander = sess.get("secondary_commander") if partner_enabled else None background_choice = sess.get("background") if partner_enabled else None ctx = orch.start_build_ctx( @@ -311,18 +378,48 @@ def step5_ctx_from_result( """ base = step5_base_ctx(request, sess, include_name=include_name, include_locks=include_locks) done = bool(res.get("done")) + include_lower = {str(name).strip().lower(): str(name) for name in (sess.get("include_cards") or []) if str(name).strip()} + exclude_lower = {str(name).strip().lower(): str(name) for name in (sess.get("exclude_cards") or []) if str(name).strip()} + + raw_added = list(res.get("added_cards", []) or []) + normalized_added: list[dict[str, Any]] = [] + for item in raw_added: + if isinstance(item, dict): + entry: dict[str, Any] = dict(item) + else: + entry = {} + try: + entry.update(vars(item)) # type: ignore[arg-type] + except Exception: + pass + # Preserve common attributes when vars() empty + for attr in ("name", "role", "sub_role", "tags", "tags_slug", "reason", "count"): + if attr not in entry and hasattr(item, attr): + try: + entry[attr] = getattr(item, attr) + except Exception: + continue + name_val = str(entry.get("name") or "").strip() + key = name_val.lower() + entry["name"] = name_val + entry["must_include"] = key in include_lower + entry["must_exclude"] = key in exclude_lower + entry["must_include_label"] = include_lower.get(key) + entry["must_exclude_label"] = exclude_lower.get(key) + normalized_added.append(entry) + ctx: Dict[str, Any] = { **base, "status": status_text, "stage_label": res.get("label"), "log": res.get("log_delta", ""), - "added_cards": res.get("added_cards", []), + "added_cards": normalized_added, "i": res.get("idx"), "n": res.get("total"), "csv_path": res.get("csv_path") if done else None, "txt_path": res.get("txt_path") if done else None, "summary": res.get("summary") if done else None, - "compliance": res.get("compliance") if done else None, + "compliance": res.get("compliance") if done else None, "show_skipped": bool(show_skipped), "total_cards": res.get("total_cards"), "added_total": res.get("added_total"), @@ -330,7 +427,7 @@ def step5_ctx_from_result( "clamped_overflow": res.get("clamped_overflow"), "mc_summary": res.get("mc_summary"), "skipped": bool(res.get("skipped")), - "gated": bool(res.get("gated")), + "gated": bool(res.get("gated")), } if extras: ctx.update(extras) @@ -344,6 +441,57 @@ def step5_ctx_from_result( ctx.update(hover_meta) if "commander_display_name" not in ctx or not ctx.get("commander_display_name"): ctx["commander_display_name"] = ctx.get("commander") + + try: + token_val = int(sess.get("step5_summary_token", 0)) + except Exception: + token_val = 0 + summary_value = ctx.get("summary") + synergies_list: list[str] = [] + if summary_value is not None: + try: + sess["step5_summary"] = summary_value + except Exception: + pass + if isinstance(summary_value, dict): + raw_synergies = summary_value.get("synergies") + if isinstance(raw_synergies, (list, tuple, set)): + synergies_list = [str(item) for item in raw_synergies if str(item).strip()] + else: + meta = summary_value.get("meta") if isinstance(summary_value.get("meta"), dict) else {} + if isinstance(meta, dict): + raw_synergies = meta.get("synergies") or meta.get("commander_synergies") + if isinstance(raw_synergies, (list, tuple, set)): + synergies_list = [str(item) for item in raw_synergies if str(item).strip()] + token_val += 1 + sess["step5_summary_token"] = token_val + sess["step5_summary_ready"] = True + if synergies_list: + sess["step5_synergies"] = synergies_list + else: + try: + if "step5_synergies" in sess: + del sess["step5_synergies"] + except Exception: + pass + else: + token_val += 1 + sess["step5_summary_token"] = token_val + sess["step5_summary_ready"] = False + try: + if "step5_summary" in sess: + del sess["step5_summary"] + except Exception: + pass + try: + if "step5_synergies" in sess: + del sess["step5_synergies"] + except Exception: + pass + synergies_list = [] + ctx["summary_token"] = token_val + ctx["summary_ready"] = bool(sess.get("step5_summary_ready")) + ctx["synergies"] = synergies_list return ctx @@ -379,6 +527,25 @@ def step5_error_ctx( "added_total": 0, "skipped": False, } + try: + token_val = int(sess.get("step5_summary_token", 0)) + 1 + except Exception: + token_val = 1 + sess["step5_summary_token"] = token_val + sess["step5_summary_ready"] = False + try: + if "step5_summary" in sess: + del sess["step5_summary"] + except Exception: + pass + try: + if "step5_synergies" in sess: + del sess["step5_synergies"] + except Exception: + pass + ctx["summary_token"] = token_val + ctx["summary_ready"] = False + ctx["synergies"] = [] if extras: ctx.update(extras) return ctx @@ -410,6 +577,25 @@ def step5_empty_ctx( "show_skipped": False, "skipped": False, } + try: + token_val = int(sess.get("step5_summary_token", 0)) + 1 + except Exception: + token_val = 1 + sess["step5_summary_token"] = token_val + sess["step5_summary_ready"] = False + try: + if "step5_summary" in sess: + del sess["step5_summary"] + except Exception: + pass + try: + if "step5_synergies" in sess: + del sess["step5_synergies"] + except Exception: + pass + ctx["summary_token"] = token_val + ctx["summary_ready"] = False + ctx["synergies"] = [] if extras: ctx.update(extras) return ctx diff --git a/code/web/services/summary_utils.py b/code/web/services/summary_utils.py index 76b5361..aee1a3f 100644 --- a/code/web/services/summary_utils.py +++ b/code/web/services/summary_utils.py @@ -2,10 +2,23 @@ from __future__ import annotations from typing import Any, Dict, Iterable, List, Optional from deck_builder import builder_constants as bc -from .build_utils import owned_set as owned_set_helper from .combo_utils import detect_for_summary as _detect_for_summary +def _owned_set_helper() -> set[str]: + try: + from .build_utils import owned_set as _owned_set # type: ignore + + return _owned_set() + except Exception: + try: + from . import owned_store + + return {str(n).strip().lower() for n in owned_store.get_names()} + except Exception: + return set() + + def _sanitize_tag_list(values: Iterable[Any]) -> List[str]: cleaned: List[str] = [] for raw in values or []: # type: ignore[arg-type] @@ -148,7 +161,7 @@ def summary_ctx( synergy_tags.append(label) versions = det.get("versions", {} if include_versions else None) return { - "owned_set": owned_set_helper(), + "owned_set": _owned_set_helper(), "game_changers": bc.GAME_CHANGERS, "combos": combos, "synergies": synergy_tags, diff --git a/code/web/services/telemetry.py b/code/web/services/telemetry.py index 151b325..dd0291f 100644 --- a/code/web/services/telemetry.py +++ b/code/web/services/telemetry.py @@ -11,10 +11,14 @@ __all__ = [ "log_commander_create_deck", "log_partner_suggestions_generated", "log_partner_suggestion_selected", + "log_include_exclude_toggle", + "log_frontend_event", ] _LOGGER = logging.getLogger("web.commander_browser") _PARTNER_LOGGER = logging.getLogger("web.partner_suggestions") +_MUST_HAVE_LOGGER = logging.getLogger("web.must_haves") +_FRONTEND_LOGGER = logging.getLogger("web.frontend_events") def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None: @@ -217,3 +221,45 @@ def log_partner_suggestion_selected( if warnings: payload["warnings"] = list(warnings) _emit(_PARTNER_LOGGER, payload) + + +def log_include_exclude_toggle( + request: Request, + *, + card_name: str, + action: str, + enabled: bool, + include_count: int, + exclude_count: int, +) -> None: + payload: Dict[str, Any] = { + "event": "must_haves.toggle", + "request_id": _request_id(request), + "path": str(request.url.path), + "card": card_name, + "list": action, + "enabled": bool(enabled), + "include_count": int(include_count), + "exclude_count": int(exclude_count), + "client_ip": _client_ip(request), + } + _emit(_MUST_HAVE_LOGGER, payload) + + +def log_frontend_event( + request: Request, + event: str, + data: Mapping[str, Any] | None, +) -> None: + snapshot: Dict[str, Any] = {} + if isinstance(data, Mapping): + snapshot = {str(k): data[k] for k in data} + payload: Dict[str, Any] = { + "event": f"frontend.{event}", + "request_id": _request_id(request), + "path": str(request.url.path), + "data": snapshot, + "referer": request.headers.get("referer"), + "client_ip": _client_ip(request), + } + _emit(_FRONTEND_LOGGER, payload) diff --git a/code/web/static/app.js b/code/web/static/app.js index eb129dc..fa1e2ad 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -57,6 +57,36 @@ } window.toastHTML = toastHTML; + var telemetryEndpoint = (function(){ + if (typeof window.__telemetryEndpoint === 'string' && window.__telemetryEndpoint.trim()){ + return window.__telemetryEndpoint.trim(); + } + return '/telemetry/events'; + })(); + var telemetry = { + send: function(eventName, data){ + if (!telemetryEndpoint || !eventName) return; + var payload; + try { + payload = JSON.stringify({ event: eventName, data: data || {}, ts: Date.now() }); + } catch(_){ return; } + try { + if (navigator.sendBeacon){ + var blob = new Blob([payload], { type: 'application/json' }); + navigator.sendBeacon(telemetryEndpoint, blob); + } else if (window.fetch){ + fetch(telemetryEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + keepalive: true, + }).catch(function(){ /* noop */ }); + } + } catch(_){ } + } + }; + window.appTelemetry = telemetry; + // Global HTMX error handling => toast document.addEventListener('htmx:responseError', function(e){ var detail = e.detail || {}; var xhr = detail.xhr || {}; @@ -136,19 +166,379 @@ } addFocusVisible(); - // Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers - function showSkeletons(container){ - (container || document).querySelectorAll('[data-skeleton]') - .forEach(function(el){ el.classList.add('is-loading'); }); + // Skeleton utility: defer placeholders until the request lasts long enough to be noticeable + var SKELETON_DELAY_DEFAULT = 400; + var skeletonTimers = new WeakMap(); + function gatherSkeletons(root){ + if (!root){ return []; } + var list = []; + var scope = (root.nodeType === 9) ? root.documentElement : root; + if (scope && scope.matches && scope.hasAttribute('data-skeleton')){ + list.push(scope); + } + if (scope && scope.querySelectorAll){ + scope.querySelectorAll('[data-skeleton]').forEach(function(el){ + if (list.indexOf(el) === -1){ list.push(el); } + }); + } + return list; } - function hideSkeletons(container){ - (container || document).querySelectorAll('[data-skeleton]') - .forEach(function(el){ el.classList.remove('is-loading'); }); + function scheduleSkeleton(el){ + var delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10); + var delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr); + clearSkeleton(el, false); + var timer = setTimeout(function(){ + el.classList.add('is-loading'); + el.setAttribute('aria-busy', 'true'); + skeletonTimers.set(el, null); + }, delay); + skeletonTimers.set(el, timer); + } + function clearSkeleton(el, removeBusy){ + var timer = skeletonTimers.get(el); + if (typeof timer === 'number'){ + clearTimeout(timer); + } + skeletonTimers.delete(el); + el.classList.remove('is-loading'); + if (removeBusy !== false){ el.removeAttribute('aria-busy'); } + } + function showSkeletons(context){ + gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); }); + } + function hideSkeletons(context){ + gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el); }); } window.skeletons = { show: showSkeletons, hide: hideSkeletons }; - document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); }); - document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); }); + document.addEventListener('htmx:beforeRequest', function(e){ + var detail = e && e.detail ? e.detail : {}; + var target = detail.target || detail.elt || e.target; + showSkeletons(target); + }); + document.addEventListener('htmx:afterSwap', function(e){ + var detail = e && e.detail ? e.detail : {}; + var target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + document.addEventListener('htmx:afterRequest', function(e){ + var detail = e && e.detail ? e.detail : {}; + var target = detail.target || detail.elt || e.target; + hideSkeletons(target); + }); + + // Commander catalog image lazy loader + (function(){ + var PLACEHOLDER_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; + var observer = null; + var supportsIO = 'IntersectionObserver' in window; + + function ensureObserver(){ + if (observer || !supportsIO) return observer; + observer = new IntersectionObserver(function(entries){ + entries.forEach(function(entry){ + if (entry.isIntersecting || entry.intersectionRatio > 0){ + var img = entry.target; + load(img); + if (observer) observer.unobserve(img); + } + }); + }, { rootMargin: '160px 0px', threshold: 0.05 }); + return observer; + } + + function load(img){ + if (!img || img.__lazyLoaded) return; + var src = img.getAttribute('data-lazy-src'); + if (src){ img.setAttribute('src', src); } + var srcset = img.getAttribute('data-lazy-srcset'); + if (srcset){ img.setAttribute('srcset', srcset); } + var sizes = img.getAttribute('data-lazy-sizes'); + if (sizes){ img.setAttribute('sizes', sizes); } + img.classList.remove('is-placeholder'); + img.removeAttribute('data-lazy-image'); + img.removeAttribute('data-lazy-src'); + img.removeAttribute('data-lazy-srcset'); + img.removeAttribute('data-lazy-sizes'); + img.__lazyLoaded = true; + } + + function prime(img){ + if (!img || img.__lazyPrimed) return; + var desired = img.getAttribute('data-lazy-src'); + if (!desired) return; + img.__lazyPrimed = true; + var placeholder = img.getAttribute('data-lazy-placeholder') || PLACEHOLDER_PIXEL; + img.setAttribute('loading', 'lazy'); + img.setAttribute('decoding', 'async'); + img.classList.add('is-placeholder'); + img.removeAttribute('srcset'); + img.removeAttribute('sizes'); + img.setAttribute('src', placeholder); + if (supportsIO){ + ensureObserver().observe(img); + } else { + var loader = window.requestIdleCallback || window.requestAnimationFrame || function(cb){ return setTimeout(cb, 0); }; + loader(function(){ load(img); }); + } + } + + function collect(scope){ + if (!scope) scope = document; + if (scope === document){ + return Array.prototype.slice.call(document.querySelectorAll('[data-lazy-image]')); + } + if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-lazy-image')){ + return [scope]; + } + if (scope.querySelectorAll){ + return Array.prototype.slice.call(scope.querySelectorAll('[data-lazy-image]')); + } + return []; + } + + function process(scope){ + collect(scope).forEach(function(img){ + if (img.__lazyLoaded) return; + prime(img); + }); + } + + if (document.readyState === 'loading'){ + document.addEventListener('DOMContentLoaded', function(){ process(document); }); + } else { + process(document); + } + + document.addEventListener('htmx:afterSwap', function(evt){ + var target = evt && evt.detail ? evt.detail.target : null; + process(target || document); + }); + })(); + + var htmxCache = (function(){ + var store = new Map(); + function ttlFor(elt){ + var raw = parseInt((elt && elt.getAttribute && elt.getAttribute('data-hx-cache-ttl')) || '', 10); + if (isNaN(raw) || raw <= 0){ return 30000; } + return Math.max(1000, raw); + } + function buildKey(detail, elt){ + if (!detail) detail = {}; + if (elt && elt.getAttribute){ + var explicit = elt.getAttribute('data-hx-cache-key'); + if (explicit && explicit.trim()){ return explicit.trim(); } + } + var verb = (detail.verb || 'GET').toUpperCase(); + var path = detail.path || ''; + var params = detail.parameters && Object.keys(detail.parameters).length ? JSON.stringify(detail.parameters) : ''; + return verb + ' ' + path + ' ' + params; + } + function set(key, html, ttl, meta){ + if (!key || typeof html !== 'string') return; + store.set(key, { + key: key, + html: html, + expires: Date.now() + (ttl || 30000), + meta: meta || {}, + }); + } + function get(key){ + if (!key) return null; + var entry = store.get(key); + if (!entry) return null; + if (entry.expires && entry.expires <= Date.now()){ + store.delete(key); + return null; + } + return entry; + } + function applyCached(elt, detail, entry){ + if (!entry) return; + var target = detail && detail.target ? detail.target : elt; + if (!target) return; + dispatchHtmx(target, 'htmx:beforeSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + var swapSpec = ''; + try { swapSpec = (elt && elt.getAttribute && elt.getAttribute('hx-swap')) || ''; } catch(_){ } + swapSpec = (swapSpec || 'innerHTML').toLowerCase(); + if (swapSpec.indexOf('outer') === 0){ + if (target.outerHTML !== undefined){ + target.outerHTML = entry.html; + } + } else if (target.innerHTML !== undefined){ + target.innerHTML = entry.html; + } + if (window.htmx && typeof window.htmx.process === 'function'){ + window.htmx.process(target); + } + dispatchHtmx(target, 'htmx:afterSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + dispatchHtmx(target, 'htmx:afterRequest', { elt: elt, target: target, cache: true, cacheKey: entry.key }); + } + function prefetch(url, opts){ + if (!url) return; + opts = opts || {}; + var key = opts.key || ('GET ' + url); + if (get(key)) return; + try { + fetch(url, { + headers: { 'HX-Request': 'true', 'Accept': 'text/html' }, + cache: 'no-store', + }).then(function(resp){ + if (!resp.ok) throw new Error('prefetch failed'); + return resp.text(); + }).then(function(html){ + set(key, html, opts.ttl || opts.cacheTtl || 30000, { url: url, prefetch: true }); + telemetry.send('htmx.cache.prefetch', { key: key, url: url }); + }).catch(function(){ /* noop */ }); + } catch(_){ } + } + return { + set: set, + get: get, + apply: applyCached, + buildKey: buildKey, + ttlFor: ttlFor, + prefetch: prefetch, + }; + })(); + window.htmxCache = htmxCache; + + document.addEventListener('htmx:configRequest', function(e){ + var detail = e && e.detail ? e.detail : {}; + var elt = detail.elt; + if (!elt || !elt.getAttribute || !elt.hasAttribute('data-hx-cache')) return; + var verb = (detail.verb || 'GET').toUpperCase(); + if (verb !== 'GET') return; + var key = htmxCache.buildKey(detail, elt); + elt.__hxCacheKey = key; + elt.__hxCacheTTL = htmxCache.ttlFor(elt); + detail.headers = detail.headers || {}; + try { detail.headers['X-HTMX-Cache-Key'] = key; } catch(_){ } + }); + + document.addEventListener('htmx:beforeRequest', function(e){ + var detail = e && e.detail ? e.detail : {}; + var elt = detail.elt; + if (!elt || !elt.__hxCacheKey) return; + var entry = htmxCache.get(elt.__hxCacheKey); + if (entry){ + telemetry.send('htmx.cache.hit', { key: elt.__hxCacheKey, path: detail.path || '' }); + e.preventDefault(); + htmxCache.apply(elt, detail, entry); + } else { + telemetry.send('htmx.cache.miss', { key: elt.__hxCacheKey, path: detail.path || '' }); + } + }); + + document.addEventListener('htmx:afterSwap', function(e){ + var detail = e && e.detail ? e.detail : {}; + var elt = detail.elt; + if (!elt || !elt.__hxCacheKey) return; + try { + var xhr = detail.xhr; + var status = xhr && xhr.status ? xhr.status : 0; + if (status >= 200 && status < 300 && xhr && typeof xhr.responseText === 'string'){ + var ttl = elt.__hxCacheTTL || 30000; + htmxCache.set(elt.__hxCacheKey, xhr.responseText, ttl, { path: detail.path || '' }); + telemetry.send('htmx.cache.store', { key: elt.__hxCacheKey, path: detail.path || '', ttl: ttl }); + } + } catch(_){ } + elt.__hxCacheKey = null; + elt.__hxCacheTTL = null; + }); + + (function(){ + function handlePrefetch(evt){ + try { + var el = evt.target && evt.target.closest ? evt.target.closest('[data-hx-prefetch]') : null; + if (!el || el.__hxPrefetched) return; + var url = el.getAttribute('data-hx-prefetch'); + if (!url) return; + el.__hxPrefetched = true; + var key = el.getAttribute('data-hx-cache-key') || el.getAttribute('data-hx-prefetch-key') || ('GET ' + url); + var ttlAttr = parseInt((el.getAttribute('data-hx-cache-ttl') || el.getAttribute('data-hx-prefetch-ttl') || ''), 10); + var ttl = isNaN(ttlAttr) ? 30000 : Math.max(1000, ttlAttr); + htmxCache.prefetch(url, { key: key, ttl: ttl }); + } catch(_){ } + } + document.addEventListener('pointerenter', handlePrefetch, true); + document.addEventListener('focusin', handlePrefetch, true); + })(); + + // Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce) + var hxDebounceGroups = new Map(); + function dispatchHtmx(el, evtName, detail){ + if (!el) return; + if (window.htmx && typeof window.htmx.trigger === 'function'){ + window.htmx.trigger(el, evtName, detail); + } else { + try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ } + } + } + function bindHtmxDebounce(el){ + if (!el || el.__hxDebounceBound) return; + el.__hxDebounceBound = true; + var delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10); + var delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw); + var eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input'; + var events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean); + if (!events.length){ events = ['input']; } + var trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput'; + var group = el.getAttribute('data-hx-debounce-group') || ''; + var flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase(); + var flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true'); + function clearTimer(){ + if (el.__hxDebounceTimer){ + clearTimeout(el.__hxDebounceTimer); + el.__hxDebounceTimer = null; + } + } + function schedule(){ + clearTimer(); + if (group){ + var prev = hxDebounceGroups.get(group); + if (prev && prev !== el && prev.__hxDebounceTimer){ + clearTimeout(prev.__hxDebounceTimer); + prev.__hxDebounceTimer = null; + } + hxDebounceGroups.set(group, el); + } + el.__hxDebounceTimer = setTimeout(function(){ + el.__hxDebounceTimer = null; + dispatchHtmx(el, trigger); + }, delay); + } + events.forEach(function(evt){ + el.addEventListener(evt, schedule, { passive: true }); + }); + if (flushOnBlur){ + el.addEventListener('blur', function(){ + if (el.__hxDebounceTimer){ + clearTimer(); + dispatchHtmx(el, trigger); + } + }); + } + el.addEventListener('htmx:beforeRequest', clearTimer); + } + function initHtmxDebounce(root){ + var scope = root || document; + if (scope === document){ scope = document.body || document; } + if (!scope) return; + var seen = new Set(); + function collect(candidate){ + if (!candidate || seen.has(candidate)) return; + seen.add(candidate); + bindHtmxDebounce(candidate); + } + if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){ + collect(scope); + } + if (scope.querySelectorAll){ + scope.querySelectorAll('[data-hx-debounce]').forEach(collect); + } + } + window.initHtmxDebounce = initHtmxDebounce; // Example: persist "show skipped" toggle if present document.addEventListener('change', function(e){ @@ -172,7 +562,9 @@ hydrateProgress(document); syncShowSkipped(document); initCardFilters(document); - initVirtualization(document); + initVirtualization(document); + initHtmxDebounce(document); + initMustHaveControls(document); }); // Hydrate progress bars with width based on data-pct @@ -200,7 +592,9 @@ hydrateProgress(e.target); syncShowSkipped(e.target); initCardFilters(e.target); - initVirtualization(e.target); + initVirtualization(e.target); + initHtmxDebounce(e.target); + initMustHaveControls(e.target); }); // Scroll a card-tile into view (cooperates with virtualization by re-rendering first) @@ -404,9 +798,8 @@ // --- Lightweight virtualization (feature-flagged via data-virtualize) --- function initVirtualization(root){ try{ - var body = document.body || document.documentElement; - var DIAG = !!(body && body.getAttribute('data-diag') === '1'); - // Global diagnostics aggregator + var body = document.body || document.documentElement; + var DIAG = !!(body && body.getAttribute('data-diag') === '1'); var GLOBAL = (function(){ if (!DIAG) return null; if (window.__virtGlobal) return window.__virtGlobal; @@ -427,7 +820,6 @@ el.style.zIndex = '50'; el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)'; el.style.cursor = 'default'; - // Hidden by default; toggle with 'v' el.style.display = 'none'; document.body.appendChild(el); store.summaryEl = el; @@ -443,7 +835,7 @@ visible += (g[i].end||0) - (g[i].start||0); lastMs = Math.max(lastMs, g[i].lastMs||0); } - el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms'; + el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+(lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs))+'ms'; } function register(gridId, ref){ store.grids.push({ id: gridId, ref: ref }); @@ -458,48 +850,66 @@ } update(); }, - toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } + toggle: function(){ + var el = ensure(); + el.style.display = (el.style.display === 'none' ? '' : 'none'); + } }; } - window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } }; + window.__virtGlobal = { + register: register, + toggle: function(){ + var el = ensure(); + el.style.display = (el.style.display === 'none' ? '' : 'none'); + } + }; return window.__virtGlobal; })(); - // Support card grids and other scroll containers (e.g., #owned-box) - var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]'); + + var scope = root || document; + if (!scope || !scope.querySelectorAll) return; + var grids = scope.querySelectorAll('[data-virtualize]'); if (!grids.length) return; + grids.forEach(function(grid){ - if (grid.__virtBound) return; - grid.__virtBound = true; - // Basic windowing: assumes roughly similar tile heights; uses sentinel measurements. + if (!grid || grid.__virtBound) return; + var attrVal = (grid.getAttribute('data-virtualize') || '').trim(); + if (!attrVal || /^0|false$/i.test(attrVal)) return; + var container = grid; container.style.position = container.style.position || 'relative'; - var wrapper = document.createElement('div'); - wrapper.className = 'virt-wrapper'; - // Ensure wrapper itself is a grid to preserve multi-column layout inside - // when the container (e.g., .card-grid) is virtualized. - wrapper.style.display = 'grid'; - // Move children into a fragment store (for owned, children live under UL) + + var mode = attrVal.toLowerCase(); + var minItemsAttr = parseInt(grid.getAttribute('data-virtualize-min') || (grid.dataset ? grid.dataset.virtualizeMin : ''), 10); + var rowAttr = parseInt(grid.getAttribute('data-virtualize-row') || (grid.dataset ? grid.dataset.virtualizeRow : ''), 10); + var colAttr = parseInt(grid.getAttribute('data-virtualize-columns') || (grid.dataset ? grid.dataset.virtualizeColumns : ''), 10); + var maxHeightAttr = grid.getAttribute('data-virtualize-max-height') || (grid.dataset ? grid.dataset.virtualizeMaxHeight : ''); + var overflowAttr = grid.getAttribute('data-virtualize-overflow') || (grid.dataset ? grid.dataset.virtualizeOverflow : ''); + var source = container; - // If this is the owned box, use the UL inside as the source list var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null; if (ownedGrid) { source = ownedGrid; } + if (!source || !source.children || !source.children.length) return; + var all = Array.prototype.slice.call(source.children); - // Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list. - // Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles) - // due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM - // is cheaper than the complexity for small sets. - var MIN_VIRT_ITEMS = 80; - if (all.length < MIN_VIRT_ITEMS){ - // Mark as processed so we don't attempt again on HTMX swaps. - return; // children remain in place; no virtualization applied. - } + all.forEach(function(node, idx){ try{ node.__virtIndex = idx; }catch(_){ } }); + var minItems = !isNaN(minItemsAttr) ? Math.max(0, minItemsAttr) : 80; + if (all.length < minItems) return; + + grid.__virtBound = true; + var store = document.createElement('div'); store.style.display = 'none'; - all.forEach(function(n){ store.appendChild(n); }); + all.forEach(function(node){ store.appendChild(node); }); + var padTop = document.createElement('div'); var padBottom = document.createElement('div'); - padTop.style.height = '0px'; padBottom.style.height = '0px'; - // For owned, keep the UL but render into it; otherwise append wrapper to container + padTop.style.height = '0px'; + padBottom.style.height = '0px'; + + var wrapper = document.createElement('div'); + wrapper.className = 'virt-wrapper'; + if (ownedGrid){ ownedGrid.innerHTML = ''; ownedGrid.appendChild(padTop); @@ -507,17 +917,34 @@ ownedGrid.appendChild(padBottom); ownedGrid.appendChild(store); } else { + container.appendChild(padTop); container.appendChild(wrapper); container.appendChild(padBottom); container.appendChild(store); } - var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height - var perRow = 1; - // Optional diagnostics overlay + + if (maxHeightAttr){ + container.style.maxHeight = maxHeightAttr; + } else if (!container.style.maxHeight){ + container.style.maxHeight = '70vh'; + } + if (overflowAttr){ + container.style.overflow = overflowAttr; + } else if (!container.style.overflow){ + container.style.overflow = 'auto'; + } + + var baseRow = container.id === 'owned-box' ? 160 : (mode.indexOf('list') > -1 ? 110 : 240); + var minRowH = !isNaN(rowAttr) && rowAttr > 0 ? rowAttr : baseRow; + var rowH = minRowH; + var explicitCols = (!isNaN(colAttr) && colAttr > 0) ? colAttr : null; + var perRow = explicitCols || 1; + var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0; var renderCount = 0; var measureCount = 0; var swapCount = 0; var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6); var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null; + function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } } function ensureDiag(){ if (!DIAG) return null; @@ -534,8 +961,7 @@ diagBox.style.fontSize = '12px'; diagBox.style.margin = '0 0 .35rem 0'; diagBox.style.color = '#cbd5e1'; - diagBox.style.display = 'none'; // hidden until toggled - // Controls + diagBox.style.display = 'none'; var controls = document.createElement('div'); controls.style.display = 'flex'; controls.style.gap = '.35rem'; @@ -543,107 +969,204 @@ controls.style.marginBottom = '.25rem'; var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af'; var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small'; - btnCopy.addEventListener('click', function(){ try{ var payload = { - id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total, - renderCount: renderCount, measureCount: measureCount, swapCount: swapCount, - lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt - }; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ } + btnCopy.addEventListener('click', function(){ + try{ + var payload = { + id: gridId, + rowH: rowH, + perRow: perRow, + start: start, + end: end, + total: total, + renderCount: renderCount, + measureCount: measureCount, + swapCount: swapCount, + lastRenderMs: lastRenderMs, + lastRenderAt: lastRenderAt, + }; + navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + btnCopy.textContent = 'Copied'; + setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); + }catch(_){ } }); var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small'; btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; }); - controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide); + controls.appendChild(title); + controls.appendChild(btnCopy); + controls.appendChild(btnHide); diagBox.appendChild(controls); var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text); var host = (container.id === 'owned-box') ? container : container.parentElement || container; host.insertBefore(diagBox, host.firstChild); return diagBox; } + function measure(){ try { measureCount++; - // create a temp tile to measure if none var probe = store.firstElementChild || all[0]; if (probe){ var fake = probe.cloneNode(true); - fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none'; + fake.style.position = 'absolute'; + fake.style.visibility = 'hidden'; + fake.style.pointerEvents = 'none'; (ownedGrid || container).appendChild(fake); var rect = fake.getBoundingClientRect(); - rowH = Math.max(120, Math.ceil(rect.height) + 16); + rowH = Math.max(minRowH, Math.ceil(rect.height) + 16); (ownedGrid || container).removeChild(fake); } - // Estimate perRow via computed styles of grid var style = window.getComputedStyle(ownedGrid || container); var cols = style.getPropertyValue('grid-template-columns'); - // Mirror grid settings onto the wrapper so its children still flow in columns try { + var displayMode = style.getPropertyValue('display'); + if (displayMode && displayMode.trim()){ + wrapper.style.display = displayMode; + } else if (!wrapper.style.display){ + wrapper.style.display = 'grid'; + } if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols; var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap'); if (gap && gap.trim()) wrapper.style.gap = gap; - // Inherit justify/align if present var ji = style.getPropertyValue('justify-items'); if (ji && ji.trim()) wrapper.style.justifyItems = ji; var ai = style.getPropertyValue('align-items'); if (ai && ai.trim()) wrapper.style.alignItems = ai; - } catch(_) {} - perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1)); - } catch(_){} + } catch(_){ } + var derivedCols = (cols && cols.split ? cols.split(' ').filter(function(x){ + return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1); + }).length : 0); + if (explicitCols){ + perRow = explicitCols; + } else if (derivedCols){ + perRow = Math.max(1, derivedCols); + } else { + perRow = Math.max(1, perRow); + } + } catch(_){ } } + measure(); var total = all.length; var start = 0, end = 0; + function render(){ var t0 = DIAG ? performance.now() : 0; var scroller = container; - var vh = scroller.clientHeight || window.innerHeight; - var scrollTop = scroller.scrollTop; - // If container isn’t scrollable, use window scroll offset - var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0); - var rowsInView = Math.ceil(vh / rowH) + 2; // overscan - var rowStart = Math.max(0, Math.floor(top / rowH) - 1); - var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow)); - var newStart = rowStart * perRow; - var newEnd = Math.min(total, rowEnd * perRow); - if (newStart === start && newEnd === end) return; // no change - start = newStart; end = newEnd; - // Padding - var beforeRows = Math.floor(start / perRow); - var afterRows = Math.ceil((total - end) / perRow); + var vh, scrollTop, top; + + if (useWindowScroll) { + // Window-scroll mode: measure relative to viewport + vh = window.innerHeight; + var rect = container.getBoundingClientRect(); + top = Math.max(0, -rect.top); + scrollTop = window.pageYOffset || document.documentElement.scrollTop || 0; + } else { + // Container-scroll mode: measure relative to container + vh = scroller.clientHeight || window.innerHeight; + scrollTop = scroller.scrollTop; + top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0); + } + + var rowsInView = Math.ceil(vh / Math.max(1, rowH)) + 2; + var rowStart = Math.max(0, Math.floor(top / Math.max(1, rowH)) - 1); + var rowEnd = Math.min(Math.ceil(top / Math.max(1, rowH)) + rowsInView, Math.ceil(total / Math.max(1, perRow))); + var newStart = rowStart * Math.max(1, perRow); + var newEnd = Math.min(total, rowEnd * Math.max(1, perRow)); + if (newStart === start && newEnd === end) return; + start = newStart; + end = newEnd; + var beforeRows = Math.floor(start / Math.max(1, perRow)); + var afterRows = Math.ceil((total - end) / Math.max(1, perRow)); padTop.style.height = (beforeRows * rowH) + 'px'; padBottom.style.height = (afterRows * rowH) + 'px'; - // Render visible children wrapper.innerHTML = ''; - for (var i = start; i < end; i++) { + for (var i = start; i < end; i++){ var node = all[i]; if (node) wrapper.appendChild(node); } if (DIAG){ var box = ensureDiag(); if (box){ - var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now(); - var vis = end - start; var rowsTotal = Math.ceil(total / perRow); + var dt = performance.now() - t0; + lastRenderMs = dt; + renderCount++; + lastRenderAt = Date.now(); + var vis = end - start; + var rowsTotal = Math.ceil(total / Math.max(1, perRow)); var textEl = box.querySelector('.virt-diag-text'); var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount; textEl.textContent = msg; - // Health hint var bad = (dt > 33) || (vis > 300); var warn = (!bad) && ((dt > 16) || (vis > 200)); box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)'); box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none'); - if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); } + if (globalReg && globalReg.set){ + globalReg.set({ total: total, start: start, end: end, lastMs: dt }); + } } } } + function onScroll(){ render(); } function onResize(){ measure(); render(); } - container.addEventListener('scroll', onScroll); + + // Support both container-scroll (default) and window-scroll modes + var scrollMode = overflowAttr || container.style.overflow || 'auto'; + var useWindowScroll = (scrollMode === 'visible' || scrollMode === 'window'); + + if (useWindowScroll) { + // Window-scroll mode: listen to window scroll events + window.addEventListener('scroll', onScroll, { passive: true }); + } else { + // Container-scroll mode: listen to container scroll events + container.addEventListener('scroll', onScroll, { passive: true }); + } window.addEventListener('resize', onResize); - // Initial size; ensure container is scrollable for our logic - if (!container.style.maxHeight) container.style.maxHeight = '70vh'; - container.style.overflow = container.style.overflow || 'auto'; + render(); - // Re-render after filters resort or HTMX swaps - document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } }); - // Keyboard toggle for overlays: 'v' + + // Track cleanup for disconnected containers + grid.__virtCleanup = function(){ + try { + if (useWindowScroll) { + window.removeEventListener('scroll', onScroll); + } else { + container.removeEventListener('scroll', onScroll); + } + window.removeEventListener('resize', onResize); + } catch(_){} + }; + + document.addEventListener('htmx:afterSwap', function(ev){ + if (!container.isConnected) return; + if (!container.contains(ev.target)) return; + swapCount++; + var merged = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); + var known = new Map(); + all.forEach(function(node, idx){ + var index = (typeof node.__virtIndex === 'number') ? node.__virtIndex : idx; + known.set(node, index); + }); + var nextIndex = known.size; + merged.forEach(function(node){ + if (!known.has(node)){ + node.__virtIndex = nextIndex; + known.set(node, nextIndex); + nextIndex++; + } + }); + merged.sort(function(a, b){ + var ia = known.get(a); + var ib = known.get(b); + return (ia - ib); + }); + merged.forEach(function(node, idx){ node.__virtIndex = idx; }); + all = merged; + total = all.length; + measure(); + render(); + }); + if (DIAG && !window.__virtHotkeyBound){ window.__virtHotkeyBound = true; document.addEventListener('keydown', function(e){ @@ -651,9 +1174,11 @@ if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; if (e.key && e.key.toLowerCase() === 'v'){ e.preventDefault(); - // Toggle all virt-diag boxes and the global summary var shown = null; - document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; }); + document.querySelectorAll('.virt-diag').forEach(function(b){ + if (shown === null) shown = (b.style.display === 'none'); + b.style.display = shown ? '' : 'none'; + }); if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle(); } }catch(_){ } @@ -663,6 +1188,137 @@ }catch(_){ } } + function setTileState(tile, type, active){ + if (!tile) return; + var attr = 'data-must-' + type; + tile.setAttribute(attr, active ? '1' : '0'); + tile.classList.toggle('must-' + type, !!active); + var selector = '.must-have-btn.' + (type === 'include' ? 'include' : 'exclude'); + try { + var btn = tile.querySelector(selector); + if (btn){ + btn.setAttribute('data-active', active ? '1' : '0'); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + btn.classList.toggle('is-active', !!active); + } + } catch(_){ } + } + + function restoreMustHaveState(tile, state){ + if (!tile || !state) return; + setTileState(tile, 'include', state.include ? 1 : 0); + setTileState(tile, 'exclude', state.exclude ? 1 : 0); + } + + function applyLocalMustHave(tile, type, enabled){ + if (!tile) return; + if (type === 'include'){ + setTileState(tile, 'include', enabled ? 1 : 0); + if (enabled){ setTileState(tile, 'exclude', 0); } + } else if (type === 'exclude'){ + setTileState(tile, 'exclude', enabled ? 1 : 0); + if (enabled){ setTileState(tile, 'include', 0); } + } + } + + function sendMustHaveRequest(tile, type, enabled, cardName, prevState){ + if (!window.htmx){ + restoreMustHaveState(tile, prevState); + tile.setAttribute('data-must-pending', '0'); + toast('Offline: cannot update preference', 'error', { duration: 4000 }); + return; + } + var summaryTarget = document.getElementById('include-exclude-summary'); + var ajaxOptions = { + source: tile, + target: summaryTarget || tile, + swap: summaryTarget ? 'outerHTML' : 'none', + values: { + card_name: cardName, + list_type: type, + enabled: enabled ? '1' : '0', + }, + }; + var xhr; + try { + xhr = window.htmx.ajax('POST', '/build/must-haves/toggle', ajaxOptions); + } catch(_){ + restoreMustHaveState(tile, prevState); + tile.setAttribute('data-must-pending', '0'); + toast('Unable to submit preference update', 'error', { duration: 4500 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'exception' }); + return; + } + if (!xhr || !xhr.addEventListener){ + tile.setAttribute('data-must-pending', '0'); + return; + } + xhr.addEventListener('load', function(evt){ + tile.setAttribute('data-must-pending', '0'); + var request = evt && evt.currentTarget ? evt.currentTarget : xhr; + var status = request.status || 0; + if (status >= 400){ + restoreMustHaveState(tile, prevState); + var msg = 'Failed to update preference'; + try { + var data = JSON.parse(request.responseText || '{}'); + if (data && data.error) msg = data.error; + } catch(_){ } + toast(msg, 'error', { duration: 5000 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: status }); + return; + } + var message; + if (enabled){ + message = (type === 'include') ? 'Pinned as must include' : 'Pinned as must exclude'; + } else { + message = (type === 'include') ? 'Removed must include' : 'Removed must exclude'; + } + toast(message + ': ' + cardName, 'success', { duration: 2400 }); + telemetry.send('must_have.toggle', { + card: cardName, + list: type, + enabled: enabled, + requestId: request.getResponseHeader ? request.getResponseHeader('X-Request-ID') : null, + }); + }); + xhr.addEventListener('error', function(){ + tile.setAttribute('data-must-pending', '0'); + restoreMustHaveState(tile, prevState); + toast('Network error updating preference', 'error', { duration: 5000 }); + telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'network' }); + }); + } + + function initMustHaveControls(root){ + var scope = root && root.querySelectorAll ? root : document; + if (scope === document && document.body) scope = document.body; + if (!scope || !scope.querySelectorAll) return; + scope.querySelectorAll('.must-have-btn').forEach(function(btn){ + if (!btn || btn.__mustHaveBound) return; + btn.__mustHaveBound = true; + var active = btn.getAttribute('data-active') === '1'; + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + btn.addEventListener('click', function(ev){ + ev.preventDefault(); + var tile = btn.closest('.card-tile'); + if (!tile) return; + if (tile.getAttribute('data-must-pending') === '1') return; + var type = btn.getAttribute('data-toggle'); + if (!type) return; + var prevState = { + include: tile.getAttribute('data-must-include') === '1', + exclude: tile.getAttribute('data-must-exclude') === '1', + }; + var nextEnabled = !(type === 'include' ? prevState.include : prevState.exclude); + var label = btn.getAttribute('data-card-label') || btn.getAttribute('data-card-name') || tile.getAttribute('data-card-name') || ''; + tile.setAttribute('data-must-pending', '1'); + applyLocalMustHave(tile, type, nextEnabled); + sendMustHaveRequest(tile, type, nextEnabled, label, prevState); + }); + }); + } + // LQIP blur/fade-in for thumbnails marked with data-lqip document.addEventListener('DOMContentLoaded', function(){ try{ @@ -673,4 +1329,61 @@ }); }catch(_){ } }); + + // --- Lazy-loading analytics accordions --- + function initLazyAccordions(root){ + try { + var scope = root || document; + if (!scope || !scope.querySelectorAll) return; + + scope.querySelectorAll('.analytics-accordion[data-lazy-load]').forEach(function(details){ + if (!details || details.__lazyBound) return; + details.__lazyBound = true; + + var loaded = false; + + details.addEventListener('toggle', function(){ + if (!details.open || loaded) return; + loaded = true; + + // Mark as loaded to prevent re-initialization + var content = details.querySelector('.analytics-content'); + if (!content) return; + + // Remove placeholder if present + var placeholder = content.querySelector('.analytics-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + // Content is already rendered in the template, just need to initialize any scripts + // Re-run virtualization if needed + try { + initVirtualization(content); + } catch(_){} + + // Re-attach chart interactivity if this is mana overview + var type = details.getAttribute('data-analytics-type'); + if (type === 'mana') { + try { + // Tooltip and highlight logic is already in the template scripts + // Just trigger a synthetic event to re-attach if needed + var event = new CustomEvent('analytics:loaded', { detail: { type: 'mana' } }); + details.dispatchEvent(event); + } catch(_){} + } + + // Send telemetry + telemetry.send('analytics.accordion_expand', { + type: type || 'unknown', + accordion: details.id || 'unnamed', + }); + }); + }); + } catch(_){} + } + + // Initialize on load and after HTMX swaps + document.addEventListener('DOMContentLoaded', function(){ initLazyAccordions(); }); + document.addEventListener('htmx:afterSwap', function(e){ initLazyAccordions(e.target); }); })(); diff --git a/code/web/static/styles.css b/code/web/static/styles.css index b0f6cc9..6992feb 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -235,7 +235,11 @@ small, .muted{ color: var(--muted); } /* Skeletons */ [data-skeleton]{ position: relative; } -[data-skeleton].is-loading > *{ opacity: 0; } +[data-skeleton].is-loading > :not([data-skeleton-placeholder]){ opacity: 0; } +[data-skeleton-placeholder]{ display:none; pointer-events:none; } +[data-skeleton].is-loading > [data-skeleton-placeholder]{ display:flex; flex-direction:column; opacity:1; } +[data-skeleton][data-skeleton-overlay="false"]::after, +[data-skeleton][data-skeleton-overlay="false"]::before{ display:none !important; } [data-skeleton]::after{ content: ''; position: absolute; inset: 0; @@ -246,6 +250,23 @@ small, .muted{ color: var(--muted); } display: none; } [data-skeleton].is-loading::after{ display:block; } +[data-skeleton].is-loading::before{ + content: attr(data-skeleton-label); + position:absolute; + top:50%; + left:50%; + transform:translate(-50%, -50%); + color: var(--muted); + font-size:.85rem; + text-align:center; + line-height:1.4; + max-width:min(92%, 360px); + padding:.3rem .5rem; + pointer-events:none; + z-index:1; + filter: drop-shadow(0 2px 4px rgba(15,23,42,.45)); +} +[data-skeleton][data-skeleton-label=""]::before{ content:''; } @keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } } /* Banner */ @@ -268,6 +289,9 @@ small, .muted{ color: var(--muted); } justify-content: start; /* pack as many as possible per row */ /* Prevent scroll chaining bounce that can cause flicker near bottom */ overscroll-behavior: contain; + content-visibility: auto; + contain: layout paint; + contain-intrinsic-size: 640px 420px; } @media (max-width: 420px){ .card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -289,10 +313,66 @@ small, .muted{ color: var(--muted); } border-color: #f5e6a8; /* soft parchment gold */ box-shadow: 0 0 0 2px rgba(245,230,168,.28) inset; } +.card-tile.must-include{ + border-color: rgba(74,222,128,.85); + box-shadow: 0 0 0 1px rgba(74,222,128,.32) inset, 0 0 12px rgba(74,222,128,.2); +} +.card-tile.must-exclude{ + border-color: rgba(239,68,68,.85); + box-shadow: 0 0 0 1px rgba(239,68,68,.35) inset; + opacity: .95; +} +.card-tile.must-include.must-exclude{ + border-color: rgba(249,115,22,.85); + box-shadow: 0 0 0 1px rgba(249,115,22,.4) inset; +} .card-tile img{ width:160px; height:auto; border-radius:6px; box-shadow: 0 6px 18px rgba(0,0,0,.35); background:#111; } .card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; } .card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; } +.must-have-controls{ + display:flex; + justify-content:center; + gap:.35rem; + flex-wrap:wrap; + margin-top:.35rem; +} +.must-have-btn{ + border:1px solid var(--border); + background:rgba(30,41,59,.6); + color:#f8fafc; + font-size:11px; + text-transform:uppercase; + letter-spacing:.06em; + padding:.25rem .6rem; + border-radius:9999px; + cursor:pointer; + transition: all .18s ease; +} +.must-have-btn.include[data-active="1"], .must-have-btn.include:hover{ + border-color: rgba(74,222,128,.75); + background: rgba(74,222,128,.18); + color: #bbf7d0; + box-shadow: 0 0 0 1px rgba(16,185,129,.25); +} +.must-have-btn.exclude[data-active="1"], .must-have-btn.exclude:hover{ + border-color: rgba(239,68,68,.75); + background: rgba(239,68,68,.18); + color: #fecaca; + box-shadow: 0 0 0 1px rgba(239,68,68,.25); +} +.must-have-btn:focus-visible{ + outline:2px solid rgba(59,130,246,.6); + outline-offset:2px; +} +.card-tile.must-exclude .must-have-btn.include[data-active="0"], +.card-tile.must-include .must-have-btn.exclude[data-active="0"]{ + opacity:.65; +} + +.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; } +.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; } + /* Shared ownership badge for card tiles and stacked images */ .owned-badge{ position:absolute; @@ -547,3 +627,54 @@ img.lqip.loaded { filter: blur(0); opacity: 1; } @media (min-width: 900px) { #test-hand { --card-w: 280px !important; --card-h: 392px !important; } } + +/* Analytics accordion styling */ +.analytics-accordion { + transition: all 0.2s ease; +} + +.analytics-accordion summary { + display: flex; + align-items: center; + justify-content: space-between; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.analytics-accordion summary:hover { + background: #1f2937; + border-color: #374151; +} + +.analytics-accordion summary:active { + transform: scale(0.99); +} + +.analytics-accordion[open] summary { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; +} + +.analytics-accordion .analytics-content { + animation: accordion-slide-down 0.3s ease-out; +} + +@keyframes accordion-slide-down { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.analytics-placeholder .skeleton-pulse { + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} diff --git a/code/web/templates/base.html b/code/web/templates/base.html index f935e4d..576d9af 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -35,6 +35,9 @@ }catch(_){ } })(); + diff --git a/code/web/templates/build/_alternatives.html b/code/web/templates/build/_alternatives.html index 36e6ee4..025c6af 100644 --- a/code/web/templates/build/_alternatives.html +++ b/code/web/templates/build/_alternatives.html @@ -3,14 +3,15 @@ { 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] } ] #} -
+
Alternatives {% set toggle_q = '0' if require_owned else '1' %} {% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}
+ hx-target="closest .alts" hx-swap="outerHTML" + data-hx-cache="1" data-hx-cache-key="alts:{{ name|lower }}:owned:{{ toggle_q }}" data-hx-cache-ttl="20000">{{ toggle_label }}
@@ -18,7 +19,7 @@ {% if not items or items|length == 0 %}
No alternatives found{{ ' (owned only)' if require_owned else '' }}.
{% else %} -
    +
      {% for it in items %} {% set badge = '✔' if it.owned else '✖' %} {% set title = 'Owned' if it.owned else 'Not owned' %} diff --git a/code/web/templates/build/_compliance_panel.html b/code/web/templates/build/_compliance_panel.html index 96890d7..e1d9f66 100644 --- a/code/web/templates/build/_compliance_panel.html +++ b/code/web/templates/build/_compliance_panel.html @@ -24,7 +24,7 @@ {# Flagged tiles by category, in the same card grid style #} {% if flagged_meta and flagged_meta|length > 0 %}
      Flagged cards
      -
      +
      = 12 %} data-virtualize="grid" data-virtualize-min="12" data-virtualize-columns="4"{% endif %}> {% for f in flagged_meta %} {% set sev = (f.severity or 'FAIL')|upper %}
      diff --git a/code/web/templates/build/_new_deck_candidates.html b/code/web/templates/build/_new_deck_candidates.html index 23d9f87..7c68d49 100644 --- a/code/web/templates/build/_new_deck_candidates.html +++ b/code/web/templates/build/_new_deck_candidates.html @@ -5,6 +5,8 @@ + +
      + {% endif %} {% if c.reason %}
      - +
      {{ c.reason }}
      {% else %}
      - +
      {% endif %}
      @@ -391,8 +402,9 @@ {% for c in added_cards %} {% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %} {% set is_locked = (locks is defined and (c.name|lower in locks)) %} -
      +
      {{ c.name }} image + {% if allow_must_haves and show_must_have_buttons %} +
      + + +
      + {% endif %} {% if c.reason %}
      - +
      {{ c.reason }}
      {% else %}
      - +
      {% endif %}
      @@ -420,7 +442,11 @@ {% endfor %}
      {% endif %} + {% if allow_must_haves and show_must_have_buttons %} +
      Tip: Use the 🔒 Lock button to preserve the current copy in the deck. “Must include” will try to pull the card back in on future reruns, while “Must exclude” blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.
      + {% else %}
      Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.
      + {% endif %} @@ -435,12 +461,17 @@ - {% if status and status.startswith('Build complete') and summary %} - - {% include "partials/include_exclude_summary.html" %} - - {% include "partials/deck_summary.html" %} + {% if allow_must_haves %} + {% include "partials/include_exclude_summary.html" with oob=False %} {% endif %} +
      +
      + {% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %} +
      +
      diff --git a/code/web/templates/build/index.html b/code/web/templates/build/index.html index bbec2f8..788ff8b 100644 --- a/code/web/templates/build/index.html +++ b/code/web/templates/build/index.html @@ -9,7 +9,7 @@ ← Back to Commanders {% endif %}
      -
      +
      diff --git a/code/web/templates/commanders/index.html b/code/web/templates/commanders/index.html index 95915b8..73c4bcc 100644 --- a/code/web/templates/commanders/index.html +++ b/code/web/templates/commanders/index.html @@ -45,30 +45,45 @@ -
      - Loading commanders… -
      -
      -
      - {% include "commanders/list_fragment.html" %} +
      + {% include "commanders/list_fragment.html" %} +
      @@ -93,6 +108,11 @@ .commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; } .commander-thumb { width:160px; flex:0 0 auto; position:relative; } .commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; } + .commander-thumb-img.is-placeholder { animation: commander-thumb-pulse 1.2s ease-in-out infinite alternate; } + @keyframes commander-thumb-pulse { + from { filter:brightness(0.45); } + to { filter:brightness(0.65); } + } .commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; } .commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; } .commander-name { margin:0; font-size:1.25rem; } @@ -121,8 +141,8 @@ .theme-suggestion-chip:hover { background:rgba(94,106,136,.28); border-color:rgba(148,163,184,.45); transform:translateY(-1px); } .theme-suggestion-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; } - .commander-loading { display:none; margin-top:1rem; } - .commander-loading.htmx-request { display:block; } + .commander-loading { margin-top:1rem; gap:1rem; } + .commander-results-swap { display:flex; flex-direction:column; } .commander-skeleton-list { display:flex; flex-direction:column; gap:1rem; } .commander-skeleton { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; } .skeleton-thumb { width:160px; height:220px; border-radius:10px; } diff --git a/code/web/templates/commanders/row_wireframe.html b/code/web/templates/commanders/row_wireframe.html index 9a7e87f..8f1d9ca 100644 --- a/code/web/templates/commanders/row_wireframe.html +++ b/code/web/templates/commanders/row_wireframe.html @@ -2,20 +2,37 @@ {% from "partials/_macros.html" import color_identity %} {% set record = entry.record %} {% set display_label = record.name if '//' in record.name else record.display_name %} +{% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
      {% set small = record.image_small_url or record.image_normal_url %} + {% set normal = record.image_normal_url or small %} {{ record.display_name }} card art +
      diff --git a/code/web/templates/partials/deck_summary.html b/code/web/templates/partials/deck_summary.html index 30c94f7..e327bef 100644 --- a/code/web/templates/partials/deck_summary.html +++ b/code/web/templates/partials/deck_summary.html @@ -1,3 +1,4 @@ +

      Deck Summary

      @@ -55,7 +56,7 @@ .dfc-land-chip.extra { border-color:#34d399; color:#a7f3d0; } .dfc-land-chip.counts { border-color:#60a5fa; color:#bfdbfe; } -
      +
      {% for c in clist %} {# Compute overlaps with detected deck synergies when available #} {% set overlaps = [] %} @@ -190,7 +191,13 @@
      -
      Mana Overview
      +
      + + Mana Overview + (pips • sources • curve) + +
      +
      Mana Overview
      {% set deck_colors = summary.colors or [] %}
      @@ -203,28 +210,26 @@ {% for color in colors %} {% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %} {% set pct = (w * 100) | int %} -
      - - {% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %} - {% set pc = pd['cards'] if 'cards' in pd else None %} - {% set c_cards = (pc[color] if pc and (color in pc) else []) %} - {% set parts = [] %} - {% for c in c_cards %} - {% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %} - {% if c.dfc %} - {% set label = label ~ ' (DFC)' %} - {% endif %} - {% set _ = parts.append(label) %} - {% endfor %} - {% set cards_line = parts|join(' • ') %} - {% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %} - +
      + {% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %} + {% set pc = pd['cards'] if 'cards' in pd else None %} + {% set c_cards = (pc[color] if pc and (color in pc) else []) %} + {% set parts = [] %} + {% for c in c_cards %} + {% set label = c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '') %} + {% if c.dfc %} + {% set label = label ~ ' (DFC)' %} + {% endif %} + {% set _ = parts.append(label) %} + {% endfor %} + {% set cards_line = parts|join(' • ') %} + {% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %} + + {% set h = (pct * 1.0) | int %} {% set bar_h = (h if h>2 else 2) %} {% set y = 118 - bar_h %} - +
      {{ color }}
      @@ -260,22 +265,20 @@ {% for color in colors %} {% set val = mg.get(color, 0) %} {% set pct = (val * 100 / denom) | int %} -
      - - {% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %} - {% set mgc = mg['cards'] if 'cards' in mg else None %} - {% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %} - {% set parts = [] %} - {% for c in c_cards %} - {% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %} - {% endfor %} - {% set cards_line = parts|join(' • ') %} - + {% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %} + {% set mgc = mg['cards'] if 'cards' in mg else None %} + {% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %} + {% set parts = [] %} + {% for c in c_cards %} + {% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %} + {% endfor %} + {% set cards_line = parts|join(' • ') %} +
      + + {% set bar_h = (pct if pct>2 else 2) %} {% set y = 118 - bar_h %} - +
      {{ color }}
      @@ -298,21 +301,19 @@ {% for label in ['0','1','2','3','4','5','6+'] %} {% set val = mc.get(label, 0) %} {% set pct = (val * 100 / denom) | int %} -
      - - {% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %} - {% set parts = [] %} - {% for c in cards %} - {% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %} - {% endfor %} - {% set cards_line = parts|join(' • ') %} - {% set pct_f = (100.0 * (val / denom)) %} - + {% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %} + {% set parts = [] %} + {% for c in cards %} + {% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %} + {% endfor %} + {% set cards_line = parts|join(' • ') %} + {% set pct_f = (100.0 * (val / denom)) %} +
      + + {% set bar_h = (pct if pct>2 else 2) %} {% set y = 118 - bar_h %} - +
      {{ label }}
      @@ -324,10 +325,18 @@ {% endif %}
      +
      +
      +
      + + Test Hand + (draw 7 random cards) + +
      Test Hand Draw 7 at random (no repeats except for basic lands).
      @@ -506,15 +515,24 @@ #test-hand.hand-row-overlap.fan .stack-grid{ padding-left:0; } } +
      +
      \ No newline at end of file + +
      \ No newline at end of file diff --git a/code/web/templates/partials/include_exclude_summary.html b/code/web/templates/partials/include_exclude_summary.html index 854dd9d..6a12595 100644 --- a/code/web/templates/partials/include_exclude_summary.html +++ b/code/web/templates/partials/include_exclude_summary.html @@ -1,3 +1,10 @@ +{% set is_oob = oob if oob is defined else False %} +
      +{% set pending_state = must_have_state if must_have_state is defined else None %} +{% set pending_includes = pending_state.includes if pending_state and pending_state.includes is not none else [] %} +{% set pending_excludes = pending_state.excludes if pending_state and pending_state.excludes is not none else [] %} +{% set has_pending = (pending_includes|length > 0) or (pending_excludes|length > 0) %} + {% if summary and summary.include_exclude_summary %} {% set ie_summary = summary.include_exclude_summary %} {% set has_data = (ie_summary.include_cards|length > 0) or (ie_summary.exclude_cards|length > 0) or (ie_summary.include_added|length > 0) or (ie_summary.excluded_removed|length > 0) %} @@ -192,4 +199,45 @@ } {% endif %} +{% elif has_pending %} +
      +
      Must-Have Selections
      +
      +
      These card lists will apply to the next build run.
      +
      +
      +
      ✓ Must Include ({{ pending_includes|length }})
      + {% if pending_includes|length %} +
      + {% for card in pending_includes %} + {{ card }} + {% endfor %} +
      + {% else %} +
      No include cards selected.
      + {% endif %} +
      +
      +
      ✗ Must Exclude ({{ pending_excludes|length }})
      + {% if pending_excludes|length %} +
      + {% for card in pending_excludes %} + {{ card }} + {% endfor %} +
      + {% else %} +
      No exclude cards selected.
      + {% endif %} +
      +
      +
      +
      +{% else %} + {% if show_must_have_buttons %} +
      +
      Must-Have Selections
      +
      Use the ✓/✗ toggles on each card to mark must-have preferences.
      +
      + {% endif %} {% endif %} +
      diff --git a/code/web/templates/themes/picker.html b/code/web/templates/themes/picker.html index ee50e8f..c596ad5 100644 --- a/code/web/templates/themes/picker.html +++ b/code/web/templates/themes/picker.html @@ -4,7 +4,8 @@
      + hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="debouncedinput change" name="q" + data-hx-debounce="260" data-hx-debounce-events="input,keyup" data-hx-debounce-flush="blur" data-hx-debounce-group="theme-search" />