mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat: add collapsible analytics, click-to-pin chart tooltips, and extended virtualization
This commit is contained in:
parent
3877890889
commit
20b9e8037c
10 changed files with 1036 additions and 202 deletions
|
|
@ -140,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'<div id="deck-summary" data-summary '
|
||||
f'hx-get="/build/step5/summary?token={token}" '
|
||||
'hx-trigger="load, step5:refresh from:body" hx-swap="outerHTML">'
|
||||
f'<div class="muted" style="margin-top:1rem;">{_esc(text)}</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
|
|
@ -772,19 +856,7 @@ async def toggle_must_haves(
|
|||
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")
|
||||
response = _render_include_exclude_summary(request, sess, sid)
|
||||
|
||||
try:
|
||||
log_include_exclude_toggle(
|
||||
|
|
@ -806,7 +878,7 @@ async def toggle_must_haves(
|
|||
"exclude_count": len(excludes),
|
||||
}
|
||||
try:
|
||||
response.headers["HX-Trigger"] = json.dumps({"must-haves:toggle": trigger_payload})
|
||||
_merge_hx_trigger(response, {"must-haves:toggle": trigger_payload})
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
|
@ -2377,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
|
||||
|
||||
|
||||
|
|
@ -3016,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)
|
||||
|
|
@ -3086,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
|
||||
|
|
@ -3100,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)
|
||||
|
|
@ -3138,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
|
||||
|
|
@ -3164,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
|
||||
|
||||
|
||||
|
|
@ -3205,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
|
||||
|
|
@ -3218,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)
|
||||
|
|
@ -3283,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")
|
||||
|
|
@ -4376,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
|
||||
|
|
@ -4443,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
|
||||
|
|
@ -4490,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
|
||||
|
||||
|
||||
|
|
@ -4519,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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue