From 8d1f6a8ac4e2e741c3667bd69b5ddd9dc43247c7 Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 26 Aug 2025 20:00:07 -0700 Subject: [PATCH] =?UTF-8?q?feat(web,docs):=20visual=20summaries=20(curve,?= =?UTF-8?q?=20pips/sources=20incl.=20'C',=20non=E2=80=91land=20sources),?= =?UTF-8?q?=20tooltip=20copy,=20favicon;=20diagnostics=20(/healthz,=20requ?= =?UTF-8?q?est=E2=80=91id,=20global=20handlers);=20fetches=20excluded,=20b?= =?UTF-8?q?asics=20CSV=20fallback,=20list=20highlight=20polish;=20README/D?= =?UTF-8?q?OCKER/release-notes/CHANGELOG=20updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 - CHANGELOG.md | 21 ++ DOCKER.md | 19 + README.md | Bin 19348 -> 25570 bytes RELEASE_NOTES_TEMPLATE.md | 20 ++ code/deck_builder/builder_utils.py | 111 ++++-- code/deck_builder/phases/phase6_reporting.py | 37 +- code/web/app.py | 84 ++++- code/web/routes/build.py | 123 ++++++- code/web/routes/owned.py | 149 ++++++++ code/web/services/orchestrator.py | 144 ++++++-- code/web/services/owned_store.py | 135 ++++++- code/web/static/app.js | 329 ++++++++++++++++++ code/web/static/favicon-small.png | Bin 0 -> 7935 bytes code/web/static/favicon.png | Bin 0 -> 9325 bytes code/web/static/styles.css | 72 ++++ code/web/templates/base.html | 20 +- .../web/templates/build/_stage_navigator.html | 25 ++ code/web/templates/build/_step1.html | 6 +- code/web/templates/build/_step2.html | 6 +- code/web/templates/build/_step3.html | 8 +- code/web/templates/build/_step4.html | 10 +- code/web/templates/build/_step5.html | 108 ++++-- code/web/templates/build/index.html | 19 +- code/web/templates/owned/index.html | 206 ++++++++++- code/web/templates/partials/deck_summary.html | 201 +++++++++-- docker-compose.yml | 3 + 27 files changed, 1704 insertions(+), 154 deletions(-) create mode 100644 code/web/static/app.js create mode 100644 code/web/static/favicon-small.png create mode 100644 code/web/static/favicon.png create mode 100644 code/web/templates/build/_stage_navigator.html diff --git a/.gitignore b/.gitignore index e629451..bb188a0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,8 @@ .mypy_cache/ .venv/ test.py -main.spec !requirements.txt __pycache__/ -#build/ csv_files/ dist/ logs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c96079f..eba83c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,16 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Recommendations export when owned-only deck is incomplete (~1.5× missing) to `deck_files/[stem]_recommendations.csv` and `.txt` - CSV export includes an `Owned` column when not using owned-only - Windows EXE build via PyInstaller is produced on tag and attached to GitHub Releases + - Prefer-owned option in Review: bias selection toward owned cards while allowing unowned fallback (stable reorder + gentle weight boosts applied across creatures and spells) + - Owned page enhancements: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling + - Finished Decks: theme filters converted to a dropdown with shareable state + - Staged build: optional "Show skipped stages" toggle to surface stages that added no cards with a clear annotation + - Owned/Not-owned badges visible across views; consolidated CSS for consistent placement + - Visual summaries: Mana Curve, Color Pips and Sources charts with cross-highlighting to cards; tooltips show per-color card lists and include a Copy action + - Source detection: include non-land mana producers and colorless 'C'; basic lands reliably counted; fetch lands excluded as sources + - Favicon support: `/favicon.ico` served (ICO with PNG fallback) + - Diagnostics: `/healthz` endpoint returns `{status, version, uptime_seconds}`; responses carry `X-Request-ID`; unhandled errors return JSON with request_id + - Tooltip Copy action on chart tooltips (Pips/Sources) for quick sharing of per-color card lists ### Changed - Rename folder from `card_library` to `owned_cards` (env override: `OWNED_CARDS_DIR`; back-compat respected) @@ -33,9 +43,20 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - Release notes source is `RELEASE_NOTES_TEMPLATE.md`; `RELEASE_NOTES.md` ignored - README/DOCKER/WINDOWS_DOCKER_GUIDE updated for Web UI, headless examples, and PowerShell-friendly commands - Headless: tag_mode (AND/OR) accepted from JSON and environment and exported in interactive run-config JSON + - Owned lists are enriched at upload-time and persisted in an internal store; header rows skipped and duplicates deduped; per-request parsing removed + - Builder Review (Step 4): "Use only owned cards" toggle moved here; Step 5 is status-only with "Edit in Review" for changes + - Minor UI/CSS polish and consolidation across builder/owned pages + - Deck summary reporting now includes colorless 'C' in totals and cards; UI adds a Show C toggle for Sources + - List view highlight polished to wrap only the card name (no overrun of the row) + - Total sources calculation updated to include 'C' properly ### Fixed - Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest` + - Owned page internal server error resolved via hardened template context and centralized owned context builder + - Web container crash resolved by removing invalid union type annotation in favicon route; route now returns a single Response type + - Source highlighting consistency: charts now correctly cross-highlight corresponding cards in both list and thumbnail views + - Basics handling: ensured basic lands and Wastes are recognized as sources; added fallback oracle text for basics in CSV export + - Fetch lands are no longer miscounted as mana sources --- diff --git a/DOCKER.md b/DOCKER.md index 37bb011..ca58875 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -34,6 +34,16 @@ docker compose up --no-deps web Then open http://localhost:8080 Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder. +The app serves a favicon at `/favicon.ico` and exposes a health endpoint at `/healthz`. + +### Setup speed: parallel tagging (Web) +First-time setup or stale data triggers card tagging. The web service uses parallel workers by default. + +Configure via environment variables on the `web` service: +- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging (default: 1) +- `WEB_TAG_WORKERS=` — number of worker processes (default: 4 in compose) + +If parallel initialization fails, the service falls back to sequential tagging and continues. ### From Docker Hub (PowerShell) If you prefer not to build locally, pull `mwisnowski/mtg-python-deckbuilder:latest` and run uvicorn: @@ -49,6 +59,11 @@ docker run --rm ` bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080" ``` +Health check: +```text +GET http://localhost:8080/healthz -> { "status": "ok", "version": "dev", "uptime_seconds": 123 } +``` + ## Volumes - `/app/deck_files` ↔ `./deck_files` - `/app/logs` ↔ `./logs` @@ -77,6 +92,10 @@ docker run --rm ` - DECK_ADD_LANDS, DECK_FETCH_COUNT - DECK_TAG_MODE=AND|OR (combine mode used by the builder) +### Web UI tuning env vars +- WEB_TAG_PARALLEL=1|0 (parallel tagging on/off) +- WEB_TAG_WORKERS= (process count; set based on CPU/memory) + ## Manual build/run ```powershell docker build -t mtg-deckbuilder . diff --git a/README.md b/README.md index 9267d49c95aec1d4d6d2bd2bdb5ecb4d0cb54ddd..ddba19a987aebd7a5ebbdafc94cef5cac01e8b10 100644 GIT binary patch delta 5778 zcmai&&2JoK62@P%QUpOLD?&CKvJo9PV6&MRBxlj?1`s$7Nni=YP67xiW6zA^DE7>( zXN>UzYVijkgc^x6XM_aul?x|sAP&oI56fS`l@mO_s;=ptu@f|!kM4dys-Al4se0#= zPp5wRcIwL?59C>Dq%2)ax6&YOrRB7)D}HV0wVXC}zoY9+-(7uP*VT%?ucnLXANu~6 z#&q@0QcH6-)7yG}N>}r#ro=JV9$J;9(i{~&)?Slj>dw~}ty@A6!11K3E!H)S=)#qHc$@_w zfhk#fT-4XUC&5S~S*<4%I2uz3bo8}-u zkyH?@V`JJ{OY7yj=}0h~%{&uHE8-N?A#=$|a>5PsxJJy}G|-BfL6l7H*j5GusGc5~ zyOf?LPkq|BZ*DhFTtX($C_c-MKAj0>_lux-!r>*GIHvWj2(%$re17y~&IqSZWpY#a zAXEg?!|#Lv2)CjsXL_R0ZH+@~Ak&5bA!BfCM_22bPtrrcOfQ78DIgcp3+YB$O3y3y zH*%5S`E*6^m-SlE``!@pUuOsadEPol_OoP8 z);Of&5F#Exim*qzR9O|;SrL9IFNx{|9q0u?(_30>K-&L(|61W5hk|(&Jp)G`7r&mLIYTT_ zF4lAHg#1+9mcG|TLsA3x5$7F!t8NzmUpRS)a>!U;yH&jQ@Zpo4^p4zx#zWDeN|{jw zEWUs4_#yVZS;C?nimACHQ=Q_23n%WU`jkbZ_;~J!^frKYL93uf9eK)~2-9R$t0c;m zWG5ZV2q7Mmv{Y4+9FfBhw>J{)V%o^%2r{;zH!<|0O(xTD@OG(-+K(Y#QjzVta&}~- zN^`kkl2ntEf%wX zJ8@{B`}7aQZYbnGK65xfqtPg=*TAYDal?rJU$VtA8F2Wv=Jxe+FdP7)IY=imp7%7v zc~Y&5-QeEWI>@WrPgIs4GIJ@4phI(}f?j-d;rL0WgFf=)M4;4D9?F)}$xavld*E0;Qvx&5)0z|0Wsbq|5oF5B{T(HqQHEl- zu~ydB3?E9q(UgH1!VxXwysq!u^#xHYNdYd_a3NvKs*1+yby!G-Fe7gWy`B+=#(lC) zCzNz(u41kM&}-Qe8m^+U-I3|FQ-OI4A;}?>FnV+x7GxQdoofozyKV!3s=gs{tr}wbM|w}R6TRFS8Gcz| z2!IhC%0neM`*}XC79Jw!&?RbI)xwR((bdz#;2G#L-m332X%^up>+vB>IV0C3zDBVA6N`S(T#KZjz2K=R_P@hNAI;a zR3U1xtZn@38!yP6aiqPvNMqj=xIhkry@~LJoo0wIp2CH?CIswW;W;-!XpFCQ&DMyV z(`*i&=qx*+mte8iu?t!Op+girHf_Ase|2u@-9>60alFuj~$l~QZE=?+o74> zMT^3%E&U=B{a-AcB1^eLz$45zGz#>2=8gaRT0EF{Mb9XYJdJh@^G2mlt2HGJ4V~ru zlp`&|fQC-@=gi7DP>&-EzQ_eAvpH9kg{nhAuc=Ry<)fMjs&x#qbSW)POF7Y)s&{MJ zAU}a&+6^JmPR-qK4*&Ow+*?Bb9}yO_x?AijO5uj@qoaO#4Ce?waR9&L`8&Xz9Iu1$ zgL_ZHyB|LL%l+zTs((uA`8lw%cqb)~bnsIn0c%?$QC9{yd$+uYAZv4)irRP@L}6qi zfRj4)_P^Bo;@wln?5O5rm`(2ksBNiLCL%*(r+Ot-7{m;^caj0EC$z*0VtATSZn(Fl sgu}7wL_H=xXEhANS%3o3)me`vS*9UVaDeG?Hi3Vx|1w}VzjS2kH{<6dHUIzs delta 45 zcmV+|0Mh^B#{ra=0kGBvv$zMQ0s%>rVFMSlMhij#vxW`u2(#=d&?>W5RqzY5)M5?+ Du8tCI diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index cdac315..c473606 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -7,11 +7,23 @@ - Owned cards workflow: Prompt after commander to "Use only owned cards?"; supports `.txt`/`.csv` lists in `owned_cards/`. Owned-only builds filter the pool; if the deck can't reach 100, it remains incomplete and notes it. When not owned-only, owned cards are marked with an `Owned` column in the final CSV. - Exports: CSV/TXT always; JSON run-config exported for interactive runs and optionally in headless (`HEADLESS_EXPORT_JSON=1`). - Data freshness: Auto-refreshes `cards.csv` if missing or older than 7 days and re-tags when needed using `.tagging_complete.json`. +- Web setup speed: initial tagging runs in parallel by default for the Web UI. Configure with `WEB_TAG_PARALLEL=1|0` and `WEB_TAG_WORKERS=` (compose default: 4). Falls back to sequential if parallel init fails. + - Visual summaries: Mana Curve, Color Pips and Sources charts with hover-to-highlight and copyable tooltips. Sources now include non-land producers and colorless 'C' (toggle display in UI). Basic lands reliably counted; fetch lands no longer miscounted as sources. + - Favicon support: app branding icon served at `/favicon.ico` (ICO/PNG fallback). + - Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts. + - Owned page: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling. Upload-time enrichment and de-duplication speeds up page loads. + - Staged build visibility: optional "Show skipped stages" reveals phases that added no cards with a clear annotation. ## What’s new - Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel. - Builder: AND-mode pre-pass for creatures; spells updated to prefer multi-tag overlap in AND mode. + - Reporting: deck summary includes per-color card lists for Pips and Sources; colorless 'C' surfaced and totals corrected. + - UI Polish: list-mode highlight wraps only the card name. Chart tooltips include a Copy action with hover persistence. + - Exports: CSV gains fallback oracle text for basic lands (Plains/Island/Swamp/Mountain/Forest/Wastes) when missing. - Config: `tag_mode` added to JSON and accepted from env (`DECK_TAG_MODE`). + - Prefer-owned bias across creatures and spells selections; Review step includes a toggle next to the owned-only control. + - Owned page features and performance improvements via upload-time enrichment and persistence. + - Staged build UI can surface skipped stages when enabled. ## Docker - CLI and Web UI in the same image. @@ -23,6 +35,10 @@ - /app/owned_cards - /app/config +### Web UI performance tuning +- `WEB_TAG_PARALLEL=1|0` — enable/disable parallel tagging during initial setup/tagging in the Web UI +- `WEB_TAG_WORKERS=` — number of worker processes (omit to auto-pick; compose default: 4) + ### Quick Start ```powershell # CLI from Docker Hub @@ -60,6 +76,10 @@ docker compose up --no-deps web - Headless: `tag_mode` supported from JSON/env and exported in interactive run-config JSON. - Docs: README, DOCKER, and Windows Docker guide updated; PowerShell-friendly examples. - Docker: compose `web` service added; volumes clarified. + - Visual summaries and diagnostics: added `/healthz` endpoint with version/uptime and request-id propagation on all responses. + - Review step consolidates owned-only and prefer-owned controls; Step 5 is status-only with an "Edit in Review" link for changes. + - Owned lists processing moved to upload-time in Web; per-request parsing removed. Enriched store powers fast Owned page and deck-building. + - Finished Decks page uses a dropdown theme filter with shareable state. ### Tagging updates - Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter. diff --git a/code/deck_builder/builder_utils.py b/code/deck_builder/builder_utils.py index d30e3f0..8e39b04 100644 --- a/code/deck_builder/builder_utils.py +++ b/code/deck_builder/builder_utils.py @@ -66,13 +66,15 @@ def normalize_theme_list(raw) -> list[str]: def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]: - """Build a matrix mapping land name -> {color: 0/1} indicating if that land - can (reliably) produce each color. + """Build a matrix mapping card name -> {color: 0/1} indicating if that card + can (reliably) produce each color of mana on the battlefield. - Heuristics: - - Presence of basic land types in type line grants that color. - - Text containing "add one mana of any color/colour" grants all colors. - - Explicit mana symbols in rules text (e.g. "{R}") grant that color. + Notes: + - Includes lands and non-lands (artifacts/creatures/enchantments/planeswalkers) that produce mana. + - Excludes instants/sorceries (rituals) by design; this is a "source" count, not ramp burst. + - Any-color effects set W/U/B/R/G (not C). Colorless '{C}' is tracked separately. + - For lands, we also infer from basic land types in the type line. For non-lands, we rely on text. + - Fallback name mapping applies only to exact basic lands (incl. Snow-Covered) and Wastes. Parameters ---------- @@ -89,29 +91,84 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[ if nm and nm not in lookup: lookup[nm] = r for name, entry in card_library.items(): - if 'land' not in str(entry.get('Card Type', '')).lower(): - continue row = lookup.get(name, {}) - tline = str(row.get('type', row.get('type_line', ''))).lower() - text_field = str(row.get('text', row.get('oracleText', ''))).lower() - colors = {c: 0 for c in COLOR_LETTERS} - if 'plains' in tline: - colors['W'] = 1 - if 'island' in tline: - colors['U'] = 1 - if 'swamp' in tline: - colors['B'] = 1 - if 'mountain' in tline: - colors['R'] = 1 - if 'forest' in tline: - colors['G'] = 1 - if 'add one mana of any color' in text_field or 'add one mana of any colour' in text_field: - for k in colors: + entry_type = str(entry.get('Card Type') or entry.get('Type') or '').lower() + tline_full = str(row.get('type', row.get('type_line', '')) or '').lower() + # Land or permanent that could produce mana via text + is_land = ('land' in entry_type) or ('land' in tline_full) + text_field = str(row.get('text', row.get('oracleText', '')) or '').lower() + # Skip obvious non-permanents (rituals etc.) + if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full): + continue + # Keep only candidates that are lands OR whose text indicates mana production + produces_from_text = False + tf = text_field + if tf: + # Common patterns: "Add {G}", "Add {C}{C}", "Add one mana of any color/colour" + produces_from_text = ( + ('add one mana of any color' in tf) or + ('add one mana of any colour' in tf) or + ('add ' in tf and ('{w}' in tf or '{u}' in tf or '{b}' in tf or '{r}' in tf or '{g}' in tf or '{c}' in tf)) + ) + if not (is_land or produces_from_text): + continue + # Combine entry type and snapshot type line for robust parsing + tline = (entry_type + ' ' + tline_full).strip() + colors = {c: 0 for c in (COLOR_LETTERS + ['C'])} + # Land type-based inference + if is_land: + if 'plains' in tline: + colors['W'] = 1 + if 'island' in tline: + colors['U'] = 1 + if 'swamp' in tline: + colors['B'] = 1 + if 'mountain' in tline: + colors['R'] = 1 + if 'forest' in tline: + colors['G'] = 1 + # Text-based inference for both lands and non-lands + if ( + 'add one mana of any color' in tf or + 'add one mana of any colour' in tf or + ('add' in tf and ('mana of any color' in tf or 'mana of any one color' in tf or 'any color of mana' in tf)) + ): + for k in COLOR_LETTERS: colors[k] = 1 - for sym, c in [(' {w}', 'W'), (' {u}', 'U'), (' {b}', 'B'), (' {r}', 'R'), (' {g}', 'G')]: - if sym in text_field: - colors[c] = 1 - matrix[name] = colors + # Explicit colored/colorless symbols in add context + if 'add' in tf: + if '{w}' in tf: + colors['W'] = 1 + if '{u}' in tf: + colors['U'] = 1 + if '{b}' in tf: + colors['B'] = 1 + if '{r}' in tf: + colors['R'] = 1 + if '{g}' in tf: + colors['G'] = 1 + if '{c}' in tf or 'colorless' in tf: + colors['C'] = 1 + # Fallback: infer only for exact basic land names (incl. Snow-Covered) and Wastes + if not any(colors.values()) and is_land: + nm = str(name) + base = nm + if nm.startswith('Snow-Covered '): + base = nm[len('Snow-Covered '):] + mapping = { + 'Plains': 'W', + 'Island': 'U', + 'Swamp': 'B', + 'Mountain': 'R', + 'Forest': 'G', + 'Wastes': 'C', + } + col = mapping.get(base) + if col: + colors[col] = 1 + # Only include cards that produced at least one color + if any(colors.values()): + matrix[name] = colors return matrix diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index f0e6bc7..cd5bc0b 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -201,6 +201,8 @@ class ReportingMixin: # Pip distribution (counts and weights) for non-land spells only pip_counts = {c: 0 for c in ('W','U','B','R','G')} + # For UI cross-highlighting: map color -> list of cards that have that color pip in their cost + pip_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G')} import re as _re_local total_pips = 0.0 for name, info in self.card_library.items(): @@ -210,11 +212,14 @@ class ReportingMixin: mana_cost = info.get('Mana Cost') or info.get('mana_cost') or '' if not isinstance(mana_cost, str): continue + # Track which colors appear for this card's mana cost for card listing + colors_for_card = set() for match in _re_local.findall(r'\{([^}]+)\}', mana_cost): sym = match.upper() if len(sym) == 1 and sym in pip_counts: pip_counts[sym] += 1 total_pips += 1 + colors_for_card.add(sym) elif '/' in sym: parts = [p for p in sym.split('/') if p in pip_counts] if parts: @@ -222,6 +227,17 @@ class ReportingMixin: for p in parts: pip_counts[p] += weight_each total_pips += weight_each + colors_for_card.add(p) + elif sym.endswith('P') and len(sym) == 2: # e.g. WP (Phyrexian) -> treat as that color + base = sym[0] + if base in pip_counts: + pip_counts[base] += 1 + total_pips += 1 + colors_for_card.add(base) + if colors_for_card: + cnt = int(info.get('Count', 1)) + for c in colors_for_card: + pip_cards[c].append({'name': name, 'count': cnt}) if total_pips <= 0: # Fallback to even distribution across color identity colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])] @@ -238,12 +254,15 @@ class ReportingMixin: matrix = _bu.compute_color_source_matrix(self.card_library, full_df) except Exception: matrix = {} - source_counts = {c: 0 for c in ('W','U','B','R','G')} + source_counts = {c: 0 for c in ('W','U','B','R','G','C')} + # For UI cross-highlighting: color -> list of cards that produce that color (typically lands, possibly others) + source_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G','C')} for name, flags in matrix.items(): copies = int(self.card_library.get(name, {}).get('Count', 1)) - for c in source_counts: + for c in source_counts.keys(): if int(flags.get(c, 0)): source_counts[c] += copies + source_cards[c].append({'name': name, 'count': copies}) total_sources = sum(source_counts.values()) # Mana curve (non-land spells) @@ -282,10 +301,12 @@ class ReportingMixin: 'pip_distribution': { 'counts': pip_counts, 'weights': pip_weights, + 'cards': pip_cards, }, 'mana_generation': { **source_counts, 'total_sources': total_sources, + 'cards': source_cards, }, 'mana_curve': { **curve_counts, @@ -393,6 +414,15 @@ class ReportingMixin: except Exception: owned_set_lower = set() + # Fallback oracle text for basic lands to ensure CSV has meaningful text + BASIC_TEXT = { + 'Plains': '({T}: Add {W}.)', + 'Island': '({T}: Add {U}.)', + 'Swamp': '({T}: Add {B}.)', + 'Mountain': '({T}: Add {R}.)', + 'Forest': '({T}: Add {G}.)', + 'Wastes': '({T}: Add {C}.)', + } for name, info in self.card_library.items(): base_type = info.get('Card Type') or info.get('Type', '') base_mc = info.get('Mana Cost', '') @@ -423,6 +453,9 @@ class ReportingMixin: power = row.get('power', '') or '' toughness = row.get('toughness', '') or '' text_field = row.get('text', row.get('oracleText', '')) or '' + # If still no text and this is a basic, inject fallback oracle snippet + if (not text_field) and (str(name) in BASIC_TEXT): + text_field = BASIC_TEXT[str(name)] # Normalize and coerce text if isinstance(text_field, str): cleaned = text_field diff --git a/code/web/app.py b/code/web/app.py index a2147d5..054e1bc 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -1,12 +1,15 @@ from __future__ import annotations -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, HTTPException from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from pathlib import Path import os import json as _json +import time +import uuid +import logging # Resolve template/static dirs relative to this file _THIS_DIR = Path(__file__).resolve().parent @@ -37,16 +40,39 @@ templates.env.globals.update({ "show_setup": SHOW_SETUP, }) +# --- Diagnostics: request-id and uptime --- +_APP_START_TIME = time.time() + +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + """Assign or propagate a request id and attach to response headers.""" + rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex + request.state.request_id = rid + try: + response = await call_next(request) + except Exception as ex: + # Log and re-raise so FastAPI exception handlers can format the response. + logging.getLogger("web").error(f"Unhandled error [rid={rid}]: {ex}", exc_info=True) + raise + response.headers["X-Request-ID"] = rid + return response + @app.get("/", response_class=HTMLResponse) async def home(request: Request) -> HTMLResponse: return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")}) -# Simple health check +# Simple health check (hardened) @app.get("/healthz") async def healthz(): - return {"status": "ok"} + try: + version = os.getenv("APP_VERSION", "dev") + uptime_s = int(time.time() - _APP_START_TIME) + return {"status": "ok", "version": version, "uptime_seconds": uptime_s} + except Exception: + # Avoid throwing from health + return {"status": "degraded"} # Lightweight setup/tagging status endpoint @app.get("/status/setup") @@ -97,6 +123,45 @@ app.include_router(decks_routes.router) app.include_router(setup_routes.router) app.include_router(owned_routes.router) +# --- Exception handling --- +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex + logging.getLogger("web").warning( + f"HTTPException [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}" + ) + # Return JSON structure suitable for HTMX or API consumers + return JSONResponse( + status_code=exc.status_code, + content={ + "error": True, + "status": exc.status_code, + "detail": exc.detail, + "request_id": rid, + "path": str(request.url.path), + }, + headers={"X-Request-ID": rid}, + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex + logging.getLogger("web").error( + f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True + ) + return JSONResponse( + status_code=500, + content={ + "error": True, + "status": 500, + "detail": "Internal Server Error", + "request_id": rid, + "path": str(request.url.path), + }, + headers={"X-Request-ID": rid}, + ) + # Lightweight file download endpoint for exports @app.get("/files") async def get_file(path: str): @@ -118,3 +183,16 @@ async def get_file(path: str): return FileResponse(path) except Exception: return PlainTextResponse("Error serving file", status_code=500) + +# Serve /favicon.ico from static (prefer .ico, fallback to .png) +@app.get("/favicon.ico") +async def favicon(): + try: + ico = _STATIC_DIR / "favicon.ico" + png = _STATIC_DIR / "favicon.png" + target = ico if ico.exists() else (png if png.exists() else None) + if target is None: + return PlainTextResponse("Not found", status_code=404) + return FileResponse(str(target)) + except Exception: + return PlainTextResponse("Error", status_code=500) diff --git a/code/web/routes/build.py b/code/web/routes/build.py index 52163fa..3030d70 100644 --- a/code/web/routes/build.py +++ b/code/web/routes/build.py @@ -15,9 +15,28 @@ router = APIRouter(prefix="/build") async def build_index(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + # Determine last step (fallback heuristics if not set) + last_step = sess.get("last_step") + if not last_step: + if sess.get("build_ctx"): + last_step = 5 + elif sess.get("ideals"): + last_step = 4 + elif sess.get("bracket"): + last_step = 3 + elif sess.get("commander"): + last_step = 2 + else: + last_step = 1 resp = templates.TemplateResponse( "build/index.html", - {"request": request, "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", [])}, + { + "request": request, + "sid": sid, + "commander": sess.get("commander"), + "tags": sess.get("tags", []), + "last_step": last_step, + }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp @@ -25,7 +44,12 @@ async def build_index(request: Request) -> HTMLResponse: @router.get("/step1", response_class=HTMLResponse) async def build_step1(request: Request) -> HTMLResponse: - return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.post("/step1", response_class=HTMLResponse) @@ -45,7 +69,10 @@ async def build_step1_search( top_name = candidates[0][0] res = orch.commander_select(top_name) if res.get("ok"): - return templates.TemplateResponse( + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 2 + resp = templates.TemplateResponse( "build/_step2.html", { "request": request, @@ -56,7 +83,12 @@ async def build_step1_search( "brackets": orch.bracket_options(), }, ) - return templates.TemplateResponse( + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 + resp = templates.TemplateResponse( "build/_step1.html", { "request": request, @@ -67,24 +99,39 @@ async def build_step1_search( "count": len(candidates) if candidates else 0, }, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.post("/step1/inspect", response_class=HTMLResponse) async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse: + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 info = orch.commander_inspect(name) - return templates.TemplateResponse( + resp = templates.TemplateResponse( "build/_step1.html", {"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)}, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.post("/step1/confirm", response_class=HTMLResponse) async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse: res = orch.commander_select(name) if not res.get("ok"): - return templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name}) + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 1 + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp # Proceed to step2 placeholder - return templates.TemplateResponse( + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 2 + resp = templates.TemplateResponse( "build/_step2.html", { "request": request, @@ -95,19 +142,24 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe "brackets": orch.bracket_options(), }, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.get("/step2", response_class=HTMLResponse) async def build_step2_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + sess["last_step"] = 2 commander = sess.get("commander") if not commander: # Fallback to step1 if no commander in session - return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []}) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp tags = orch.tags_for_commander(commander) selected = sess.get("tags", []) - return templates.TemplateResponse( + resp = templates.TemplateResponse( "build/_step2.html", { "request": request, @@ -123,6 +175,8 @@ async def build_step2_get(request: Request) -> HTMLResponse: "tag_mode": sess.get("tag_mode", "AND"), }, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.post("/step2", response_class=HTMLResponse) @@ -138,7 +192,10 @@ async def build_step2_submit( # Validate primary tag selection if tags are available available_tags = orch.tags_for_commander(commander) if available_tags and not (primary_tag and primary_tag.strip()): - return templates.TemplateResponse( + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + sess["last_step"] = 2 + resp = templates.TemplateResponse( "build/_step2.html", { "request": request, @@ -155,6 +212,8 @@ async def build_step2_submit( "tag_mode": (tag_mode or "AND"), }, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp # Save selection to session (basic MVP; real build will use this later) sid = request.cookies.get("sid") or new_sid() @@ -164,7 +223,8 @@ async def build_step2_submit( sess["tag_mode"] = (tag_mode or "AND").upper() sess["bracket"] = int(bracket) # Proceed to Step 3 placeholder for now - return templates.TemplateResponse( + sess["last_step"] = 3 + resp = templates.TemplateResponse( "build/_step3.html", { "request": request, @@ -176,6 +236,8 @@ async def build_step2_submit( "values": orch.ideal_defaults(), }, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.post("/step3", response_class=HTMLResponse) @@ -220,7 +282,8 @@ async def build_step3_submit( if errors: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) - return templates.TemplateResponse( + sess["last_step"] = 3 + resp = templates.TemplateResponse( "build/_step3.html", { "request": request, @@ -233,6 +296,8 @@ async def build_step3_submit( "bracket": sess.get("bracket"), }, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp # Save to session sid = request.cookies.get("sid") or new_sid() @@ -240,7 +305,8 @@ async def build_step3_submit( sess["ideals"] = submitted # Proceed to review (Step 4) - return templates.TemplateResponse( + sess["last_step"] = 4 + resp = templates.TemplateResponse( "build/_step4.html", { "request": request, @@ -249,12 +315,15 @@ async def build_step3_submit( "commander": sess.get("commander"), }, ) + resp.set_cookie("sid", sid, httponly=True, samesite="lax") + return resp @router.get("/step3", response_class=HTMLResponse) async def build_step3_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + sess["last_step"] = 3 defaults = orch.ideal_defaults() values = sess.get("ideals") or defaults resp = templates.TemplateResponse( @@ -277,6 +346,7 @@ async def build_step3_get(request: Request) -> HTMLResponse: async def build_step4_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + sess["last_step"] = 4 labels = orch.ideal_labels() values = sess.get("ideals") or orch.ideal_defaults() commander = sess.get("commander") @@ -302,6 +372,7 @@ async def build_toggle_owned_review( """Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4.""" sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + sess["last_step"] = 4 only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False sess["use_owned_only"] = only_val @@ -329,6 +400,7 @@ async def build_toggle_owned_review( async def build_step5_get(request: Request) -> HTMLResponse: sid = request.cookies.get("sid") or new_sid() sess = get_session(sid) + sess["last_step"] = 5 resp = templates.TemplateResponse( "build/_step5.html", { @@ -344,6 +416,12 @@ async def build_step5_get(request: Request) -> HTMLResponse: "stage_label": None, "log": None, "added_cards": [], + "i": None, + "n": None, + "total_cards": None, + "added_total": 0, + "show_skipped": False, + "skipped": False, "game_changers": bc.GAME_CHANGERS, }, ) @@ -383,10 +461,12 @@ async def build_step5_continue(request: Request) -> HTMLResponse: prefer_owned=prefer, owned_names=owned_names, ) - show_skipped = True if (request.query_params.get('show_skipped') == '1' or (await request.form().get('show_skipped', None) == '1') if hasattr(request, 'form') else False) else False + # Read show_skipped from either query or form safely + show_skipped = True if (request.query_params.get('show_skipped') == '1') else False try: form = await request.form() - show_skipped = True if (form.get('show_skipped') == '1') else show_skipped + if form and form.get('show_skipped') == '1': + show_skipped = True except Exception: pass res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) @@ -400,6 +480,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse: csv_path = res.get("csv_path") if res.get("done") else None txt_path = res.get("txt_path") if res.get("done") else None summary = res.get("summary") if res.get("done") else None + total_cards = res.get("total_cards") + added_total = res.get("added_total") + sess["last_step"] = 5 resp = templates.TemplateResponse( "build/_step5.html", { @@ -422,6 +505,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse: "summary": summary, "game_changers": bc.GAME_CHANGERS, "show_skipped": show_skipped, + "total_cards": total_cards, + "added_total": added_total, + "skipped": bool(res.get("skipped")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -474,6 +560,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: csv_path = res.get("csv_path") if res.get("done") else None txt_path = res.get("txt_path") if res.get("done") else None summary = res.get("summary") if res.get("done") else None + total_cards = res.get("total_cards") + added_total = res.get("added_total") + sess["last_step"] = 5 resp = templates.TemplateResponse( "build/_step5.html", { @@ -496,6 +585,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse: "summary": summary, "game_changers": bc.GAME_CHANGERS, "show_skipped": show_skipped, + "total_cards": total_cards, + "added_total": added_total, + "skipped": bool(res.get("skipped")), }, ) resp.set_cookie("sid", sid, httponly=True, samesite="lax") @@ -554,6 +646,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: csv_path = res.get("csv_path") if res.get("done") else None txt_path = res.get("txt_path") if res.get("done") else None summary = res.get("summary") if res.get("done") else None + sess["last_step"] = 5 resp = templates.TemplateResponse( "build/_step5.html", { diff --git a/code/web/routes/owned.py b/code/web/routes/owned.py index 7f20f43..e29145a 100644 --- a/code/web/routes/owned.py +++ b/code/web/routes/owned.py @@ -76,6 +76,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str """ # Read enriched data from the store (fast path; avoids per-request CSV parsing) names, tags_by_name, type_by_name, colors_by_name = store.get_enriched() + added_at_map = store.get_added_at_map() + user_tags_map = store.get_user_tags_map() # Default sort by name (case-insensitive) names_sorted = sorted(names, key=lambda s: s.lower()) # Build filter option sets @@ -95,6 +97,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str "all_tags": all_tags, "all_colors": all_colors, "color_combos": combos, + "added_at_map": added_at_map, + "user_tags_map": user_tags_map, } if notice: ctx["notice"] = notice @@ -139,6 +143,85 @@ async def owned_clear(request: Request) -> HTMLResponse: return templates.TemplateResponse("owned/index.html", ctx) +@router.post("/remove", response_class=HTMLResponse) +async def owned_remove(request: Request) -> HTMLResponse: + """Remove a set of names provided as JSON or form data under 'names'.""" + try: + names: list[str] = [] + # Try JSON first + try: + payload = await request.json() + if isinstance(payload, dict) and isinstance(payload.get("names"), list): + names = [str(x) for x in payload.get("names")] + elif isinstance(payload, list): + names = [str(x) for x in payload] + except Exception: + # Fallback to form field 'names' as comma-separated + form = await request.form() + raw = form.get("names") or "" + if raw: + names = [s.strip() for s in str(raw).split(',') if s.strip()] + removed, total = store.remove_names(names) + notice = f"Removed {removed} name(s). Total: {total}." + ctx = _build_owned_context(request, notice=notice) + return templates.TemplateResponse("owned/index.html", ctx) + except Exception as e: + ctx = _build_owned_context(request, error=f"Remove failed: {e}") + return templates.TemplateResponse("owned/index.html", ctx) + + +@router.post("/tag/add", response_class=HTMLResponse) +async def owned_tag_add(request: Request) -> HTMLResponse: + try: + names: list[str] = [] + tag: str = "" + try: + payload = await request.json() + if isinstance(payload, dict): + if isinstance(payload.get("names"), list): + names = [str(x) for x in payload.get("names")] + tag = str(payload.get("tag") or "").strip() + except Exception: + form = await request.form() + raw = form.get("names") or "" + if raw: + names = [s.strip() for s in str(raw).split(',') if s.strip()] + tag = str(form.get("tag") or "").strip() + updated = store.add_user_tag(names, tag) + notice = f"Added tag '{tag}' to {updated} name(s)." + ctx = _build_owned_context(request, notice=notice) + return templates.TemplateResponse("owned/index.html", ctx) + except Exception as e: + ctx = _build_owned_context(request, error=f"Tag add failed: {e}") + return templates.TemplateResponse("owned/index.html", ctx) + + +@router.post("/tag/remove", response_class=HTMLResponse) +async def owned_tag_remove(request: Request) -> HTMLResponse: + try: + names: list[str] = [] + tag: str = "" + try: + payload = await request.json() + if isinstance(payload, dict): + if isinstance(payload.get("names"), list): + names = [str(x) for x in payload.get("names")] + tag = str(payload.get("tag") or "").strip() + except Exception: + form = await request.form() + raw = form.get("names") or "" + if raw: + names = [s.strip() for s in str(raw).split(',') if s.strip()] + tag = str(form.get("tag") or "").strip() + updated = store.remove_user_tag(names, tag) + notice = f"Removed tag '{tag}' from {updated} name(s)." + ctx = _build_owned_context(request, notice=notice) + return templates.TemplateResponse("owned/index.html", ctx) + except Exception as e: + ctx = _build_owned_context(request, error=f"Tag remove failed: {e}") + return templates.TemplateResponse("owned/index.html", ctx) + + # Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step. @@ -177,3 +260,69 @@ async def owned_export_csv() -> Response: media_type="text/csv; charset=utf-8", headers={"Content-Disposition": "attachment; filename=owned_cards.csv"}, ) + + +@router.post("/export-visible") +async def owned_export_visible_txt(request: Request) -> Response: + """Download the provided names (visible subset) as TXT.""" + try: + names: list[str] = [] + try: + payload = await request.json() + if isinstance(payload, dict) and isinstance(payload.get("names"), list): + names = [str(x) for x in payload.get("names")] + elif isinstance(payload, list): + names = [str(x) for x in payload] + except Exception: + form = await request.form() + raw = form.get("names") or "" + if raw: + names = [s.strip() for s in str(raw).split(',') if s.strip()] + # Stable case-insensitive sort + lines = "\n".join(sorted((names or []), key=lambda s: s.lower())) + return Response( + content=lines + ("\n" if lines else ""), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=owned_visible.txt"}, + ) + except Exception: + # On error return empty file + return Response(content="", media_type="text/plain; charset=utf-8") + + +@router.post("/export-visible.csv") +async def owned_export_visible_csv(request: Request) -> Response: + """Download the provided names (visible subset) with enrichment as CSV.""" + try: + names: list[str] = [] + try: + payload = await request.json() + if isinstance(payload, dict) and isinstance(payload.get("names"), list): + names = [str(x) for x in payload.get("names")] + elif isinstance(payload, list): + names = [str(x) for x in payload] + except Exception: + form = await request.form() + raw = form.get("names") or "" + if raw: + names = [s.strip() for s in str(raw).split(',') if s.strip()] + # Build CSV using current enrichment + all_names, tags_by_name, type_by_name, colors_by_name = store.get_enriched() + import csv + from io import StringIO + buf = StringIO() + writer = csv.writer(buf) + writer.writerow(["Name", "Type", "Colors", "Tags"]) + for n in sorted((names or []), key=lambda s: s.lower()): + tline = type_by_name.get(n, "") + cols = ''.join(colors_by_name.get(n, []) or []) + tags = '|'.join(tags_by_name.get(n, []) or []) + writer.writerow([n, tline, cols, tags]) + content = buf.getvalue() + return Response( + content=content, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=owned_visible.csv"}, + ) + except Exception: + return Response(content="", media_type="text/csv; charset=utf-8") diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index a6319a2..e7def18 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -548,55 +548,92 @@ def _ensure_setup_ready(out, force: bool = False) -> None: out(f"Initial setup failed: {e}") _write_status({"running": False, "phase": "error", "message": f"Initial setup failed: {e}"}) return - # Tagging with granular color progress + # Tagging with progress; support parallel workers for speed try: from tagging import tagger as _tagger # type: ignore from settings import COLORS as _COLORS # type: ignore colors = list(_COLORS) total = len(colors) + use_parallel = str(os.getenv('WEB_TAG_PARALLEL', '1')).strip().lower() in {"1","true","yes","on"} + max_workers_env = os.getenv('WEB_TAG_WORKERS') + try: + max_workers = int(max_workers_env) if max_workers_env else None + except Exception: + max_workers = None _write_status({ "running": True, "phase": "tagging", - "message": "Tagging cards (this may take a while)...", + "message": "Tagging cards (this may take a while)..." if not use_parallel else "Tagging cards in parallel...", "color": None, "percent": 0, "color_idx": 0, "color_total": total, "tagging_started_at": _dt.now().isoformat(timespec='seconds') }) - for idx, _color in enumerate(colors, start=1): + + if use_parallel: try: - pct = int((idx - 1) * 100 / max(1, total)) - # Estimate ETA based on average time per completed color - eta_s = None - try: - from datetime import datetime as __dt - ts = __dt.fromisoformat(json.load(open(os.path.join('csv_files', '.setup_status.json'), 'r', encoding='utf-8')).get('tagging_started_at')) # type: ignore - elapsed = max(0.0, (_dt.now() - ts).total_seconds()) - completed = max(0, idx - 1) - if completed > 0: - avg = elapsed / completed - remaining = max(0, total - completed) - eta_s = int(avg * remaining) - except Exception: - eta_s = None - payload = { - "running": True, - "phase": "tagging", - "message": f"Tagging {_color}...", - "color": _color, - "percent": pct, - "color_idx": idx, - "color_total": total, - } - if eta_s is not None: - payload["eta_seconds"] = eta_s - _write_status(payload) - _tagger.load_dataframe(_color) + import concurrent.futures as _f + completed = 0 + with _f.ProcessPoolExecutor(max_workers=max_workers) as ex: + fut_map = {ex.submit(_tagger.load_dataframe, c): c for c in colors} + for fut in _f.as_completed(fut_map): + c = fut_map[fut] + try: + fut.result() + completed += 1 + pct = int(completed * 100 / max(1, total)) + _write_status({ + "running": True, + "phase": "tagging", + "message": f"Tagged {c}", + "color": c, + "percent": pct, + "color_idx": completed, + "color_total": total, + }) + except Exception as e: + out(f"Parallel tagging failed for {c}: {e}") + _write_status({"running": False, "phase": "error", "message": f"Tagging {c} failed: {e}", "color": c}) + return except Exception as e: - out(f"Tagging {_color} failed: {e}") - _write_status({"running": False, "phase": "error", "message": f"Tagging {_color} failed: {e}", "color": _color}) - return + out(f"Parallel tagging init failed: {e}; falling back to sequential") + use_parallel = False + + if not use_parallel: + for idx, _color in enumerate(colors, start=1): + try: + pct = int((idx - 1) * 100 / max(1, total)) + # Estimate ETA based on average time per completed color + eta_s = None + try: + from datetime import datetime as __dt + ts = __dt.fromisoformat(json.load(open(os.path.join('csv_files', '.setup_status.json'), 'r', encoding='utf-8')).get('tagging_started_at')) # type: ignore + elapsed = max(0.0, (_dt.now() - ts).total_seconds()) + completed = max(0, idx - 1) + if completed > 0: + avg = elapsed / completed + remaining = max(0, total - completed) + eta_s = int(avg * remaining) + except Exception: + eta_s = None + payload = { + "running": True, + "phase": "tagging", + "message": f"Tagging {_color}...", + "color": _color, + "percent": pct, + "color_idx": idx, + "color_total": total, + } + if eta_s is not None: + payload["eta_seconds"] = eta_s + _write_status(payload) + _tagger.load_dataframe(_color) + except Exception as e: + out(f"Tagging {_color} failed: {e}") + _write_status({"running": False, "phase": "error", "message": f"Tagging {_color} failed: {e}", "color": _color}) + return except Exception as e: out(f"Tagging failed to start: {e}") _write_status({"running": False, "phase": "error", "message": f"Tagging failed to start: {e}"}) @@ -1117,6 +1154,21 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal # If this stage added cards, present it and advance idx if added_cards: + # Progress counts + try: + total_cards = 0 + for _n, _e in getattr(b, 'card_library', {}).items(): + try: + total_cards += int(_e.get('Count', 1)) + except Exception: + total_cards += 1 + except Exception: + total_cards = None + added_total = 0 + try: + added_total = sum(int(c.get('count', 0) or 0) for c in added_cards) + except Exception: + added_total = 0 ctx["snapshot"] = snap_before # snapshot for rerun ctx["idx"] = i + 1 ctx["last_visible_idx"] = i + 1 @@ -1127,10 +1179,22 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "added_cards": added_cards, "idx": i + 1, "total": len(stages), + "total_cards": total_cards, + "added_total": added_total, } # No cards added: either skip or surface as a 'skipped' stage if show_skipped: + # Progress counts even when skipped + try: + total_cards = 0 + for _n, _e in getattr(b, 'card_library', {}).items(): + try: + total_cards += int(_e.get('Count', 1)) + except Exception: + total_cards += 1 + except Exception: + total_cards = None ctx["snapshot"] = snap_before ctx["idx"] = i + 1 ctx["last_visible_idx"] = i + 1 @@ -1142,6 +1206,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "skipped": True, "idx": i + 1, "total": len(stages), + "total_cards": total_cards, + "added_total": 0, } # No cards added and not showing skipped: advance to next @@ -1194,6 +1260,16 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal _json.dump(payload, f, ensure_ascii=False, indent=2) except Exception: pass + # Final progress + try: + total_cards = 0 + for _n, _e in getattr(b, 'card_library', {}).items(): + try: + total_cards += int(_e.get('Count', 1)) + except Exception: + total_cards += 1 + except Exception: + total_cards = None return { "done": True, "label": "Complete", @@ -1203,4 +1279,6 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal "csv_path": ctx.get("csv_path"), "txt_path": ctx.get("txt_path"), "summary": summary, + "total_cards": total_cards, + "added_total": 0, } diff --git a/code/web/services/owned_store.py b/code/web/services/owned_store.py index 809beb0..eab044b 100644 --- a/code/web/services/owned_store.py +++ b/code/web/services/owned_store.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Iterable, List, Tuple, Dict import json import os +import time def _owned_dir() -> Path: @@ -108,6 +109,16 @@ def add_names(names: Iterable[str]) -> Tuple[int, int]: data["names"] = cur if "meta" not in data or not isinstance(data.get("meta"), dict): data["meta"] = {} + meta = data["meta"] + now = int(time.time()) + # Ensure newly added names have an added_at + for s in cur: + info = meta.get(s) + if not info: + meta[s] = {"added_at": now} + else: + if "added_at" not in info: + info["added_at"] = now _save_raw(data) return added, len(cur) @@ -263,10 +274,16 @@ def add_and_enrich(names: Iterable[str]) -> Tuple[int, int]: continue # Enrich meta = data.get("meta") or {} + now = int(time.time()) if new_names: enriched = _enrich_from_csvs(new_names) for nm, info in enriched.items(): meta[nm] = info + # Stamp added_at for new names if missing + for nm in new_names: + entry = meta.setdefault(nm, {}) + if "added_at" not in entry: + entry["added_at"] = now data["names"] = current_names data["meta"] = meta _save_raw(data) @@ -285,7 +302,15 @@ def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dic colors_by_name: Dict[str, List[str]] = {} for n in names: info = meta.get(n) or {} - tags = info.get('tags') or [] + tags = (info.get('tags') or []) + user_tags = (info.get('user_tags') or []) + if user_tags: + # merge user tags (unique, case-insensitive) + seen = {str(t).lower() for t in tags} + for ut in user_tags: + if str(ut).lower() not in seen: + (tags or []).append(str(ut)) + seen.add(str(ut).lower()) typ = info.get('type') or None cols = info.get('colors') or [] if tags: @@ -297,6 +322,114 @@ def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dic return names, tags_by_name, type_by_name, colors_by_name +def add_user_tag(names: Iterable[str], tag: str) -> int: + """Add a user-defined tag to the given names; returns number of names updated.""" + t = str(tag or '').strip() + if not t: + return 0 + data = _load_raw() + cur = [str(x).strip() for x in (data.get('names') or []) if str(x).strip()] + target = {str(n).strip().lower() for n in (names or []) if str(n).strip()} + meta = data.get('meta') or {} + updated = 0 + for s in cur: + if s.lower() not in target: + continue + entry = meta.setdefault(s, {}) + arr = entry.get('user_tags') or [] + if not any(str(x).strip().lower() == t.lower() for x in arr): + arr.append(t) + entry['user_tags'] = arr + updated += 1 + data['meta'] = meta + _save_raw(data) + return updated + + +def remove_user_tag(names: Iterable[str], tag: str) -> int: + """Remove a user-defined tag from the given names; returns number of names updated.""" + t = str(tag or '').strip() + if not t: + return 0 + data = _load_raw() + cur = [str(x).strip() for x in (data.get('names') or []) if str(x).strip()] + target = {str(n).strip().lower() for n in (names or []) if str(n).strip()} + meta = data.get('meta') or {} + updated = 0 + for s in cur: + if s.lower() not in target: + continue + entry = meta.get(s) or {} + arr = [x for x in (entry.get('user_tags') or []) if str(x)] + before = len(arr) + arr = [x for x in arr if str(x).strip().lower() != t.lower()] + if len(arr) != before: + entry['user_tags'] = arr + meta[s] = entry + updated += 1 + data['meta'] = meta + _save_raw(data) + return updated + + +def get_added_at_map() -> Dict[str, int]: + """Return a mapping of name -> added_at unix timestamp (if known).""" + data = _load_raw() + meta: Dict[str, Dict[str, object]] = data.get("meta") or {} + out: Dict[str, int] = {} + for n, info in meta.items(): + try: + ts = info.get("added_at") + if isinstance(ts, (int, float)): + out[n] = int(ts) + except Exception: + continue + return out + + +def remove_names(names: Iterable[str]) -> Tuple[int, int]: + """Remove a batch of names; returns (removed_count, total_after).""" + target = {str(n).strip().lower() for n in (names or []) if str(n).strip()} + if not target: + return 0, len(get_names()) + data = _load_raw() + cur = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()] + before = len(cur) + cur_kept: List[str] = [] + for s in cur: + if s.lower() in target: + continue + cur_kept.append(s) + removed = before - len(cur_kept) + data["names"] = cur_kept + meta = data.get("meta") or {} + # Drop meta entries for removed names + for s in list(meta.keys()): + try: + if s.lower() in target: + meta.pop(s, None) + except Exception: + continue + data["meta"] = meta + _save_raw(data) + return removed, len(cur_kept) + + +def get_user_tags_map() -> Dict[str, list[str]]: + """Return a mapping of name -> list of user-defined tags (if any).""" + data = _load_raw() + meta: Dict[str, Dict[str, object]] = data.get("meta") or {} + out: Dict[str, list[str]] = {} + for n, info in meta.items(): + try: + arr = [x for x in (info.get("user_tags") or []) if str(x)] + if arr: + out[n] = [str(x) for x in arr] + except Exception: + continue + return out + + def parse_txt_bytes(content: bytes) -> List[str]: out: List[str] = [] try: diff --git a/code/web/static/app.js b/code/web/static/app.js new file mode 100644 index 0000000..6aad0f9 --- /dev/null +++ b/code/web/static/app.js @@ -0,0 +1,329 @@ +/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */ +(function(){ + // Design tokens fallback (in case CSS variables missing in older browsers) + // No-op here since styles.css defines variables; kept for future JS reads. + + // State persistence helpers (localStorage + URL hash) + var state = { + get: function(key, def){ + try { var v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; } + }, + set: function(key, val){ + try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){} + }, + inHash: function(obj){ + // Merge obj into location.hash as query-like params + try { + var params = new URLSearchParams((location.hash||'').replace(/^#/, '')); + Object.keys(obj||{}).forEach(function(k){ params.set(k, obj[k]); }); + location.hash = params.toString(); + } catch(e){} + }, + readHash: function(){ + try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); } + } + }; + window.__mtgState = state; + + // Toast system + var toastHost; + function ensureToastHost(){ + if (!toastHost){ + toastHost = document.createElement('div'); + toastHost.className = 'toast-host'; + document.body.appendChild(toastHost); + } + return toastHost; + } + function toast(msg, type, opts){ + ensureToastHost(); + var t = document.createElement('div'); + t.className = 'toast' + (type ? ' '+type : ''); + t.setAttribute('role','status'); + t.setAttribute('aria-live','polite'); + t.textContent = msg; + toastHost.appendChild(t); + var delay = (opts && opts.duration) || 2600; + setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay); + return t; + } + window.toast = toast; + + // Global HTMX error handling => toast + document.addEventListener('htmx:responseError', function(e){ + var detail = e.detail || {}; var xhr = detail.xhr || {}; + var msg = 'Action failed'; + try { if (xhr.responseText) msg += ': ' + xhr.responseText.slice(0,140); } catch(_){} + toast(msg, 'error', { duration: 5000 }); + }); + document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); }); + + // Keyboard shortcuts + var keymap = { + ' ': function(){ var el = document.querySelector('[data-action="continue"], .btn-continue'); if (el) el.click(); }, + 'r': function(){ var el = document.querySelector('[data-action="rerun"], .btn-rerun'); if (el) el.click(); }, + 'b': function(){ var el = document.querySelector('[data-action="back"], .btn-back'); if (el) el.click(); }, + 'l': function(){ var el = document.querySelector('[data-action="toggle-logs"], .btn-logs'); if (el) el.click(); }, + }; + document.addEventListener('keydown', function(e){ + if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs + var k = e.key.toLowerCase(); + if (keymap[k]){ e.preventDefault(); keymap[k](); } + }); + + // Focus ring visibility for keyboard nav + function addFocusVisible(){ + var hadKeyboardEvent = false; + function onKeyDown(){ hadKeyboardEvent = true; } + function onPointer(){ hadKeyboardEvent = false; } + function onFocus(e){ if (hadKeyboardEvent) e.target.classList.add('focus-visible'); } + function onBlur(e){ e.target.classList.remove('focus-visible'); } + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('mousedown', onPointer, true); + window.addEventListener('pointerdown', onPointer, true); + window.addEventListener('touchstart', onPointer, true); + document.addEventListener('focusin', onFocus); + document.addEventListener('focusout', onBlur); + } + 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'); }); + } + function hideSkeletons(container){ + (container || document).querySelectorAll('[data-skeleton]') + .forEach(function(el){ el.classList.remove('is-loading'); }); + } + window.skeletons = { show: showSkeletons, hide: hideSkeletons }; + + document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); }); + document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); }); + + // Example: persist "show skipped" toggle if present + document.addEventListener('change', function(e){ + var el = e.target; + if (el && el.matches('[data-pref]')){ + var key = el.getAttribute('data-pref'); + var val = (el.type === 'checkbox') ? !!el.checked : el.value; + state.set(key, val); + state.inHash((function(o){ o[key] = val; return o; })({})); + } + }); + // On load, initialize any data-pref elements + document.addEventListener('DOMContentLoaded', function(){ + document.querySelectorAll('[data-pref]').forEach(function(el){ + var key = el.getAttribute('data-pref'); + var saved = state.get(key, undefined); + if (typeof saved !== 'undefined'){ + if (el.type === 'checkbox') el.checked = !!saved; else el.value = saved; + } + }); + hydrateProgress(document); + syncShowSkipped(document); + initCardFilters(document); + }); + + // Hydrate progress bars with width based on data-pct + function hydrateProgress(root){ + (root || document).querySelectorAll('.progress[data-pct]') + .forEach(function(p){ + var pct = parseInt(p.getAttribute('data-pct') || '0', 10); + if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100; + var bar = p.querySelector('.bar'); if (!bar) return; + // Animate width for a bit of delight + requestAnimationFrame(function(){ bar.style.width = pct + '%'; }); + }); + } + // Keep hidden inputs for show_skipped in sync with the sticky checkbox + function syncShowSkipped(root){ + var cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]'); + if (!cb) return; + var val = cb.checked ? '1' : '0'; + (root || document).querySelectorAll('section form').forEach(function(f){ + var h = f.querySelector('input[name="show_skipped"]'); + if (h) h.value = val; + }); + } + document.addEventListener('htmx:afterSwap', function(e){ + hydrateProgress(e.target); + syncShowSkipped(e.target); + initCardFilters(e.target); + }); + + // --- Card grid filters, reasons, and collapsible groups --- + function initCardFilters(root){ + var section = (root || document).querySelector('section'); + if (!section) return; + var toolbar = section.querySelector('.cards-toolbar'); + if (!toolbar) return; // nothing to do + var q = toolbar.querySelector('input[name="filter_query"]'); + var ownedSel = toolbar.querySelector('select[name="filter_owned"]'); + var showReasons = toolbar.querySelector('input[name="show_reasons"]'); + var collapseGroups = toolbar.querySelector('input[name="collapse_groups"]'); + var resultsEl = toolbar.querySelector('[data-results]'); + var emptyEl = section.querySelector('[data-empty]'); + var sortSel = toolbar.querySelector('select[name="filter_sort"]'); + var chipOwned = toolbar.querySelector('[data-chip-owned="owned"]'); + var chipNot = toolbar.querySelector('[data-chip-owned="not"]'); + var chipAll = toolbar.querySelector('[data-chip-owned="all"]'); + var chipClear = toolbar.querySelector('[data-chip-clear]'); + + function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; } + // Read URL hash on first init to hydrate controls + try { + var params = window.__mtgState.readHash(); + if (params){ + var hv = params.get('q'); if (q && hv !== null) q.value = hv; + hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv; + hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1'); + hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1'); + hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv; + } + } catch(_){} + function apply(){ + var query = (getVal(q)+ '').toLowerCase().trim(); + var ownedMode = (getVal(ownedSel) || 'all'); + var showR = !!getVal(showReasons); + var collapse = !!getVal(collapseGroups); + var sortMode = (getVal(sortSel) || 'az'); + // Toggle reasons visibility via section class + section.classList.toggle('hide-reasons', !showR); + // Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state + section.querySelectorAll('.group').forEach(function(wrapper){ + var grid = wrapper.querySelector('.group-grid'); if (!grid) return; + var key = wrapper.getAttribute('data-group-key'); + if (collapse){ + grid.setAttribute('data-collapsed','1'); + } else { + // restore stored + if (key){ + var stored = state.get('cards:group:'+key, null); + if (stored === true){ grid.setAttribute('data-collapsed','1'); } + else { grid.removeAttribute('data-collapsed'); } + } else { + grid.removeAttribute('data-collapsed'); + } + } + }); + // Filter tiles + var tiles = section.querySelectorAll('.card-grid .card-tile'); + var visible = 0; + tiles.forEach(function(tile){ + var name = (tile.getAttribute('data-card-name')||'').toLowerCase(); + var role = (tile.getAttribute('data-role')||'').toLowerCase(); + var tags = (tile.getAttribute('data-tags')||'').toLowerCase(); + var owned = tile.getAttribute('data-owned') === '1'; + var text = name + ' ' + role + ' ' + tags; + var qOk = !query || text.indexOf(query) !== -1; + var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned); + var show = qOk && oOk; + tile.style.display = show ? '' : 'none'; + if (show) visible++; + }); + // Sort within each grid + function keyFor(tile){ + var name = (tile.getAttribute('data-card-name')||''); + var owned = tile.getAttribute('data-owned') === '1' ? 1 : 0; + var gc = tile.classList.contains('game-changer') ? 1 : 0; + return { name: name.toLowerCase(), owned: owned, gc: gc }; + } + section.querySelectorAll('.card-grid').forEach(function(grid){ + var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile')); + arr.sort(function(a,b){ + var ka = keyFor(a), kb = keyFor(b); + if (sortMode === 'owned'){ + if (kb.owned !== ka.owned) return kb.owned - ka.owned; + if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next + return ka.name.localeCompare(kb.name); + } else if (sortMode === 'gc'){ + if (kb.gc !== ka.gc) return kb.gc - ka.gc; + if (kb.owned !== ka.owned) return kb.owned - ka.owned; + return ka.name.localeCompare(kb.name); + } + // default A–Z + return ka.name.localeCompare(kb.name); + }); + arr.forEach(function(el){ grid.appendChild(el); }); + }); + // Update group counts based on visible tiles within each group + section.querySelectorAll('.group').forEach(function(wrapper){ + var grid = wrapper.querySelector('.group-grid'); + var count = 0; + if (grid){ + grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; }); + } + var cEl = wrapper.querySelector('[data-count]'); + if (cEl) cEl.textContent = count; + }); + if (resultsEl) resultsEl.textContent = String(visible); + if (emptyEl) emptyEl.hidden = (visible !== 0); + // Persist prefs + if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value); + if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value); + if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked); + if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked); + if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value); + // Update URL hash for shareability + try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ } + } + // Wire events + if (q) q.addEventListener('input', apply); + if (ownedSel) ownedSel.addEventListener('change', apply); + if (showReasons) showReasons.addEventListener('change', apply); + if (collapseGroups) collapseGroups.addEventListener('change', apply); + if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); }); + if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); }); + if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); }); + if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); }); + // Individual group toggles + section.querySelectorAll('.group-header .toggle').forEach(function(btn){ + btn.addEventListener('click', function(){ + var wrapper = btn.closest('.group'); + var grid = wrapper && wrapper.querySelector('.group-grid'); + if (!grid) return; + var key = wrapper.getAttribute('data-group-key'); + var willCollapse = !grid.getAttribute('data-collapsed'); + if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed'); + if (key){ state.set('cards:group:'+key, !!willCollapse); } + // ARIA + btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true'); + }); + }); + // Per-card reason toggle: delegate clicks on .btn-why + section.addEventListener('click', function(e){ + var t = e.target; + if (!t || !t.classList || !t.classList.contains('btn-why')) return; + e.preventDefault(); + var tile = t.closest('.card-tile'); + if (!tile) return; + var globalHidden = section.classList.contains('hide-reasons'); + if (globalHidden){ + // Force-show overrides global hidden + var on = tile.classList.toggle('force-show'); + if (on) tile.classList.remove('force-hide'); + t.textContent = on ? 'Hide why' : 'Why?'; + } else { + // Hide this tile only + var off = tile.classList.toggle('force-hide'); + if (off) tile.classList.remove('force-show'); + t.textContent = off ? 'Show why' : 'Hide why'; + } + }); + // Initial apply on hydrate + apply(); + + // Keyboard helpers: '/' focuses query, Esc clears + function onKey(e){ + // avoid when typing in inputs + if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; + if (e.key === '/'){ + if (q){ e.preventDefault(); q.focus(); q.select && q.select(); } + } else if (e.key === 'Escape'){ + if (q && q.value){ q.value=''; apply(); } + } + } + document.addEventListener('keydown', onKey); + } +})(); diff --git a/code/web/static/favicon-small.png b/code/web/static/favicon-small.png new file mode 100644 index 0000000000000000000000000000000000000000..153f624133a725be006c89b7dd60955ffd6ddb9b GIT binary patch literal 7935 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 literal 0 HcmV?d00001 diff --git a/code/web/static/favicon.png b/code/web/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..b00f12391f1719802e75a59e2329861c71cb9f6b GIT binary patch literal 9325 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 w`Ld