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 43ff9aa..d8bfbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,23 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### Summary -- Phase 1 responsiveness tweaks: shared HTMX debounce helper, deferred skeleton microcopy, and containment rules for long card lists. +- 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. ### Added - 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`. ### Changed - Commander quick-start and theme picker searches route through a centralized `data-hx-debounce` helper so rapid keystrokes coalesce into a single HTMX request. - Card grids and alternative lists opt into `content-visibility`/`contain` to reduce layout churn on large decks. +- Build wizard Step 5 now emits optimistic include/exclude updates using cached HTMX fragments, prefetch metadata, and persistent summary containers for pending must-have selections. +- Skeleton utility supports opt-in placeholder blocks (`data-skeleton-placeholder`) and overlay suppression for complex shimmer layouts. +- Commander catalog route caches filter results and page renders (plus startup prewarm) so repeated catalog loads avoid recomputing the entire dataset. +- Must-have include/exclude buttons are hidden by default behind a new `SHOW_MUST_HAVE_BUTTONS` env toggle and now ship with tooltips explaining how they differ from locks. ### 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..254bd04 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 @@ -684,6 +688,129 @@ 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 + + must_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)), + } + + ctx = step5_base_ctx(request, sess, include_name=False, include_locks=False) + ctx["must_have_state"] = must_state + ctx["summary"] = None + response = templates.TemplateResponse("partials/include_exclude_summary.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + + 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: + response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload}) + except Exception: + pass + return response + # Alternatives cache moved to services/alts_utils @@ -1132,6 +1259,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 +1659,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 +1683,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 +1787,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 +1925,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, 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..fc1f83e 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,36 @@ 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", [])) + 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 +131,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,12 +365,42 @@ 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, 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 2156977..022607c 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 || {}; @@ -197,14 +227,252 @@ 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){ + function dispatchHtmx(el, evtName, detail){ if (!el) return; if (window.htmx && typeof window.htmx.trigger === 'function'){ - window.htmx.trigger(el, evtName); + window.htmx.trigger(el, evtName, detail); } else { - try { el.dispatchEvent(new Event(evtName, { bubbles: true })); } catch(_){ } + try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ } } } function bindHtmxDebounce(el){ @@ -296,6 +564,7 @@ initCardFilters(document); initVirtualization(document); initHtmxDebounce(document); + initMustHaveControls(document); }); // Hydrate progress bars with width based on data-pct @@ -325,6 +594,7 @@ initCardFilters(e.target); initVirtualization(e.target); initHtmxDebounce(e.target); + initMustHaveControls(e.target); }); // Scroll a card-tile into view (cooperates with virtualization by re-rendering first) @@ -787,6 +1057,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{ diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 9f800da..d0979b3 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; @@ -309,10 +313,63 @@ 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; } 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 6e86bf9..025c6af 100644 --- a/code/web/templates/build/_alternatives.html +++ b/code/web/templates/build/_alternatives.html @@ -10,7 +10,8 @@ {% set toggle_label = 'Owned only: On' if require_owned else 'Owned only: Off' %}