diff --git a/.env.example b/.env.example index 756be71..01e035b 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,11 @@ 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) +ENABLE_BUDGET_MODE=1 # dockerhub: ENABLE_BUDGET_MODE="1" (1=enable budget mode controls and reporting) +BUDGET_POOL_TOLERANCE=0.15 # dockerhub: BUDGET_POOL_TOLERANCE="0.15" (fractional overhead above per-card ceiling before pool exclusion; overridden per-build by UI field) +PRICE_AUTO_REFRESH=0 # dockerhub: PRICE_AUTO_REFRESH="0" (1=rebuild price cache daily at 01:00 UTC) +PRICE_LAZY_REFRESH=1 # dockerhub: PRICE_LAZY_REFRESH="1" (1=refresh stale per-card prices in background) +PRICE_STALE_WARNING_HOURS=24 # dockerhub: PRICE_STALE_WARNING_HOURS="24" (hours before a cached price shows ⏱ stale indicator; 0=disable) WEB_THEME_PICKER_DIAGNOSTICS=1 # dockerhub: WEB_THEME_PICKER_DIAGNOSTICS="1" ENABLE_CARD_DETAILS=1 # dockerhub: ENABLE_CARD_DETAILS="1" SIMILARITY_CACHE_ENABLED=1 # dockerhub: SIMILARITY_CACHE_ENABLED="1" diff --git a/.github/workflows/build-similarity-cache.yml b/.github/workflows/build-similarity-cache.yml index 5814bf3..15faab2 100644 --- a/.github/workflows/build-similarity-cache.yml +++ b/.github/workflows/build-similarity-cache.yml @@ -104,6 +104,18 @@ jobs: exit 1 fi + - name: Refresh price data and write to parquet + if: steps.check_cache.outputs.needs_build == 'true' + run: | + # refresh_prices_parquet() downloads fresh Scryfall bulk data and rebuilds the cache + python -c "from code.file_setup.setup import refresh_prices_parquet; refresh_prices_parquet(print)" + + if [ ! -f "card_files/prices_cache.json" ]; then + echo "ERROR: Price cache not created" + exit 1 + fi + echo "Price data refreshed successfully" + # Debug step - uncomment if needed to inspect Parquet file contents # - name: Debug - Inspect Parquet file after tagging # if: steps.check_cache.outputs.needs_build == 'true' @@ -264,9 +276,11 @@ jobs: ## Files - `card_files/similarity_cache.parquet` - Pre-computed card similarity cache - `card_files/similarity_cache_metadata.json` - Cache metadata - - `card_files/processed/all_cards.parquet` - Tagged card database + - `card_files/processed/all_cards.parquet` - Tagged card database (with prices) - `card_files/processed/commander_cards.parquet` - Commander-only cache (fast lookups) - `card_files/processed/.tagging_complete.json` - Tagging status + - `card_files/prices_cache.json` - Scryfall price cache + - `card_files/prices_cache.json.ts` - Per-card price timestamps (if present) EOF # Start with clean index @@ -278,6 +292,8 @@ jobs: git add -f card_files/processed/all_cards.parquet git add -f card_files/processed/commander_cards.parquet git add -f card_files/processed/.tagging_complete.json + git add -f card_files/prices_cache.json + git add -f card_files/prices_cache.json.ts 2>/dev/null || true git add -f README-cache.md # Create a new commit diff --git a/CHANGELOG.md b/CHANGELOG.md index f02477f..ca726e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,39 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning - **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md` - **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox - **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection) +- **Budget Mode**: Full budget-aware deck building with price integration + - Budget configuration on the New Deck modal: set a total budget cap, optional per-card ceiling, and soft/hard enforcement mode + - Price display during building: card prices shown next to card names in list and thumbnail views throughout the build pipeline + - Running budget counter chip updates as each build stage completes + - Over-budget card highlight: cards exceeding the per-card ceiling are marked with a yellow/gold border + - Basic lands excluded from all budget calculations + - Budget summary bar in the deck summary view with under/over color coding + - Budget badge and over-budget panel on the saved deck view + - Pickups list page (`/decks/{name}/pickups`) sorted by priority tier + - Pool budget filter: cards exceeding the per-card ceiling by more than the pool tolerance (default 15%, configurable per build in the New Deck modal) are excluded from the candidate pool before building begins + - Card price shown in the hover and tap popup for all card tiles with a cached price + - Price shown inline on each alternative card suggestion in the alternatives panel + - Post-build budget review panel appears when the final deck total exceeds the budget cap by more than 10%; lists over-budget cards sorted by overage with up to 3 cheaper alternatives each + - Alternatives in the review panel are matched by card type (lands suggest land alternatives, creatures suggest creature alternatives) and sorted by role similarity using shared strategy tags + - Each alternative has a Swap button that replaces the card in the finalized deck and re-evaluates the budget live; the panel auto-dismisses when the total drops within tolerance + - "Accept deck as-is" button in soft mode lets you bypass the review and proceed to export + - Build complete screen shows a minimal action bar (Restart build / New build / Back) instead of the full stage ribbon + - Controlled by `ENABLE_BUDGET_MODE` environment variable (default: enabled) +- **Price Cache Infrastructure**: Improved price data lifecycle + - `price` and `price_updated` columns added to parquet card database via `refresh_prices_parquet()` + - `PRICE_AUTO_REFRESH=1`: optional daily 1 AM UTC scheduled price cache rebuild + - `PRICE_LAZY_REFRESH=1`: background per-card price refresh for cards not updated in 7 days (default: enabled) + - `POST /api/price/refresh`: manual price cache rebuild trigger + - "Card Price Cache Status" section on the Setup page with last-updated date and Refresh button + - Footer now shows the price data date alongside the Scryfall attribution +- **Price charts**: Visual cost breakdown added to the deck summary and build complete screens + - Donut/bar chart showing total deck spend by card role category (commander, ramp, card draw, lands, etc.) + - Price histogram showing card count distribution across cost buckets + - Basic lands excluded from all chart calculations +- **Stale price warnings**: Cards with price data older than 24 hours are flagged with a subtle clock indicator (⏱) on card tiles, the hover popup, the budget review panel, and the Pickups page; if more than half the deck's prices are stale a single banner is shown instead of per-card indicators; controlled by `PRICE_STALE_WARNING_HOURS` (default: 24; set to 0 to disable) ### Changed -_No unreleased changes yet_ +- **Create Button in New Dock Panel**: Button has been renamed to "Build Deck" for consistency with phrasing on the "Quick Build" button ### Fixed - **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed diff --git a/DOCKER.md b/DOCKER.md index 398120e..61e1a10 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -257,6 +257,9 @@ See `.env.example` for the full catalog. Common knobs: | `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`). | +| `ENABLE_BUDGET_MODE` | `1` | Enable budget mode controls (total cap, per-card ceiling, soft/hard enforcement) and price display throughout the builder. | +| `PRICE_AUTO_REFRESH` | `0` | Rebuild the price cache automatically once daily at 01:00 UTC. | +| `PRICE_LAZY_REFRESH` | `1` | Refresh per-card prices in the background when they are more than 7 days old (uses Scryfall named-card API with rate-limit delay). | | `THEME` | `dark` | Initial UI theme (`system`, `light`, or `dark`). | | `WEB_STAGE_ORDER` | `new` | Build stage execution order: `new` (creatures→spells→lands) or `legacy` (lands→creatures→spells). | | `WEB_IDEALS_UI` | `slider` | Ideal counts interface: `slider` (range inputs with live validation) or `input` (text boxes with placeholders). | diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 0625893..5ec5bb7 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -2,17 +2,48 @@ ## [Unreleased] ### Added -- **RandomService**: Service wrapper for seeded RNG with validation (`code/web/services/random_service.py`) -- **Random diagnostics**: `GET /api/random/diagnostics` endpoint (requires `WEB_RANDOM_DIAGNOSTICS=1`) -- **Random Mode docs**: `docs/random_mode/` covering seed infrastructure, developer guide, and diagnostics -- **Multi-copy include dialog**: Typing a multi-copy archetype card (e.g., Hare Apparent) in Must Include now triggers a popup to choose copy count and optional Thrumming Stone inclusion -- **Multi-copy/exclude conflict dialog**: Selecting a multi-copy archetype while the same card is in the Exclude list now shows a resolution popup — keep the archetype (removes from excludes) or keep the exclude (disables archetype) +- **RandomService**: New `code/web/services/random_service.py` service class wrapping seeded RNG operations with input validation and the R9 `BaseService` pattern +- **InvalidSeedError**: New `InvalidSeedError` exception in `code/exceptions.py` for seed validation failures +- **Random diagnostics endpoint**: `GET /api/random/diagnostics` behind `WEB_RANDOM_DIAGNOSTICS=1` flag, returning seed derivation test vectors for cross-platform consistency checks +- **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md` +- **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox +- **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection) +- **Budget Mode**: Full budget-aware deck building with price integration + - Budget configuration on the New Deck modal: set a total budget cap, optional per-card ceiling, and soft/hard enforcement mode + - Price display during building: card prices shown next to card names in list and thumbnail views throughout the build pipeline + - Running budget counter chip updates as each build stage completes + - Over-budget card highlight: cards exceeding the per-card ceiling are marked with a yellow/gold border + - Basic lands excluded from all budget calculations + - Budget summary bar in the deck summary view with under/over color coding + - Budget badge and over-budget panel on the saved deck view + - Pickups list page (`/decks/{name}/pickups`) sorted by priority tier + - Pool budget filter: cards exceeding the per-card ceiling by more than the pool tolerance (default 15%, configurable per build in the New Deck modal) are excluded from the candidate pool before building begins + - Card price shown in the hover and tap popup for all card tiles with a cached price + - Price shown inline on each alternative card suggestion in the alternatives panel + - Post-build budget review panel appears when the final deck total exceeds the budget cap by more than 10%; lists over-budget cards sorted by overage with up to 3 cheaper alternatives each + - Alternatives in the review panel are matched by card type (lands suggest land alternatives, creatures suggest creature alternatives) and sorted by role similarity using shared strategy tags + - Each alternative has a Swap button that replaces the card in the finalized deck and re-evaluates the budget live; the panel auto-dismisses when the total drops within tolerance + - "Accept deck as-is" button in soft mode lets you bypass the review and proceed to export + - Build complete screen shows a minimal action bar (Restart build / New build / Back) instead of the full stage ribbon + - Controlled by `ENABLE_BUDGET_MODE` environment variable (default: enabled) +- **Price Cache Infrastructure**: Improved price data lifecycle + - `price` and `price_updated` columns added to parquet card database via `refresh_prices_parquet()` + - `PRICE_AUTO_REFRESH=1`: optional daily 1 AM UTC scheduled price cache rebuild + - `PRICE_LAZY_REFRESH=1`: background per-card price refresh for cards not updated in 7 days (default: enabled) + - `POST /api/price/refresh`: manual price cache rebuild trigger + - "Card Price Cache Status" section on the Setup page with last-updated date and Refresh button + - Footer now shows the price data date alongside the Scryfall attribution +- **Price charts**: Visual cost breakdown added to the deck summary and build complete screens + - Donut/bar chart showing total deck spend by card role category (commander, ramp, card draw, lands, etc.) + - Price histogram showing card count distribution across cost buckets + - Basic lands excluded from all chart calculations +- **Stale price warnings**: Cards with price data older than 24 hours are flagged with a subtle clock indicator (⏱) on card tiles, the hover popup, the budget review panel, and the Pickups page; if more than half the deck's prices are stale a single banner is shown instead of per-card indicators; controlled by `PRICE_STALE_WARNING_HOURS` (default: 24; set to 0 to disable) ### Changed -_No unreleased changes yet_ +- **Create Button in New Dock Panel**: Button has been renamed to "Build Deck" for consistency with phrasing on the "Quick Build" button ### Fixed -- **Multi-copy include count**: Archetype cards in Must Include now inject the correct count instead of always adding 1 copy +- **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed ### Removed _No unreleased changes yet_ diff --git a/code/deck_builder/builder.py b/code/deck_builder/builder.py index 79b2ace..6b9c7e3 100644 --- a/code/deck_builder/builder.py +++ b/code/deck_builder/builder.py @@ -1367,6 +1367,68 @@ class DeckBuilder( self._full_cards_df = combined.copy() return combined + def apply_budget_pool_filter(self) -> None: + """M4: Remove cards priced above the per-card ceiling × (1 + tolerance) from the pool. + + Must be called AFTER budget_config is set on the builder instance. + Fail-open: skipped if price column absent, no ceiling configured, or any exception occurs. + Include-list cards are never filtered regardless of price. + """ + import logging + _logger = logging.getLogger(__name__) + + budget_config = getattr(self, 'budget_config', None) or {} + ceiling = budget_config.get('card_ceiling') + if not ceiling or ceiling <= 0: + return + + df = getattr(self, '_combined_cards_df', None) + if df is None or not hasattr(df, 'columns'): + return + + if 'price' not in df.columns: + _logger.warning("BUDGET_POOL_FILTER: 'price' column absent — skipping pool filter") + return + + # Tolerance: per-build user value > env var > constant default + tol = budget_config.get('pool_tolerance') + if tol is None: + import os as _os + env_tol = _os.getenv('BUDGET_POOL_TOLERANCE') + try: + tol = float(env_tol) if env_tol else bc.BUDGET_POOL_TOLERANCE + except ValueError: + tol = bc.BUDGET_POOL_TOLERANCE + max_price = ceiling * (1.0 + tol) + + # Include-list cards always pass regardless of price + include_lower: set[str] = set() + try: + for nm in (getattr(self, 'include_cards', None) or []): + include_lower.add(str(nm).strip().lower()) + except Exception: + pass + + before = len(df) + try: + price_ok = df['price'].isna() | (df['price'] <= max_price) + if include_lower and 'name' in df.columns: + protected = df['name'].str.strip().str.lower().isin(include_lower) + df = df[price_ok | protected] + else: + df = df[price_ok] + except Exception as exc: + _logger.error(f"BUDGET_POOL_FILTER: filter failed: {exc}") + return + + removed = before - len(df) + if removed: + _logger.info( + f"BUDGET_POOL_FILTER: removed {removed} cards above ${max_price:.2f} " + f"(ceiling=${ceiling:.2f}, tol={tol * 100:.0f}%)" + ) + self._combined_cards_df = df + # --------------------------- # Include/Exclude Processing (M1: Config + Validation + Persistence) # --------------------------- diff --git a/code/deck_builder/builder_constants.py b/code/deck_builder/builder_constants.py index 02e2054..1dd8365 100644 --- a/code/deck_builder/builder_constants.py +++ b/code/deck_builder/builder_constants.py @@ -163,6 +163,8 @@ PRICE_CACHE_SIZE: Final[int] = 128 # Size of price check LRU cache PRICE_CHECK_TIMEOUT: Final[int] = 30 # Timeout for price check requests in seconds PRICE_TOLERANCE_MULTIPLIER: Final[float] = 1.1 # Multiplier for price tolerance DEFAULT_MAX_CARD_PRICE: Final[float] = 20.0 # Default maximum price per card +BUDGET_POOL_TOLERANCE: Final[float] = 0.15 # Default pool filter tolerance (15% overhead above per-card ceiling) +BUDGET_TOTAL_TOLERANCE: Final[float] = 0.10 # End-of-build review threshold (10% grace on total deck cost) # Deck composition defaults DEFAULT_RAMP_COUNT: Final[int] = 8 # Default number of ramp pieces diff --git a/code/deck_builder/phases/phase6_reporting.py b/code/deck_builder/phases/phase6_reporting.py index 3044736..5d9dc95 100644 --- a/code/deck_builder/phases/phase6_reporting.py +++ b/code/deck_builder/phases/phase6_reporting.py @@ -798,18 +798,24 @@ class ReportingMixin: except Exception: # pragma: no cover - diagnostics only logger.debug("Failed to record theme telemetry", exc_info=True) return summary_payload - def export_decklist_csv(self, directory: str = 'deck_files', filename: str | None = None, suppress_output: bool = False) -> str: - """Export current decklist to CSV (enriched). - Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv - Included columns: Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text. - Falls back gracefully if snapshot rows missing. - """ + def export_decklist_csv( + self, + directory: str = 'deck_files', + filename: str | None = None, + suppress_output: bool = False, + price_lookup: Any | None = None, + ) -> str: """Export current decklist to CSV (enriched). Filename pattern (default): commanderFirstWord_firstTheme_YYYYMMDD.csv Included columns (enriched when possible): - Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text + Name, Count, Type, ManaCost, ManaValue, Colors, Power, Toughness, Role, Tags, Text, Price Falls back gracefully if snapshot rows missing. + + Args: + price_lookup: Optional callable (list[str] -> dict[str, float|None]) used to + batch-look up prices at export time. When omitted the Price column + is written but left blank for every card. """ os.makedirs(directory, exist_ok=True) def _slug(s: str) -> str: @@ -882,9 +888,18 @@ class ReportingMixin: headers = [ "Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness", - "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned" + "Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned","Price" ] + # Batch price lookup (no-op when price_lookup not provided) + card_names_list = list(self.card_library.keys()) + prices_map: Dict[str, Any] = {} + if callable(price_lookup): + try: + prices_map = price_lookup(card_names_list) or {} + except Exception: + prices_map = {} + header_suffix: List[str] = [] try: commander_meta = self.get_commander_export_metadata() @@ -1024,7 +1039,8 @@ class ReportingMixin: metadata_tags_join, # M5: Include metadata tags text_field[:800] if isinstance(text_field, str) else str(text_field)[:800], dfc_note, - owned_flag + owned_flag, + (f"{prices_map[name]:.2f}" if prices_map.get(name) is not None else '') ])) # Now sort (category precedence, then alphabetical name) @@ -1038,6 +1054,19 @@ class ReportingMixin: w.writerow(data_row + suffix_padding) else: w.writerow(data_row) + # Summary row: total price in the Price column (blank when no prices available) + if prices_map: + total_price = sum( + v for v in prices_map.values() if v is not None + ) + price_col_index = headers.index('Price') + summary_row = [''] * len(headers) + summary_row[0] = 'Total' + summary_row[price_col_index] = f'{total_price:.2f}' + if suffix_padding: + w.writerow(summary_row + suffix_padding) + else: + w.writerow(summary_row) self.output_func(f"Deck exported to {fname}") # Auto-generate matching plaintext list (best-effort; ignore failures) diff --git a/code/exceptions.py b/code/exceptions.py index af9d16a..85d803b 100644 --- a/code/exceptions.py +++ b/code/exceptions.py @@ -811,6 +811,33 @@ class IdealDeterminationError(DeckBuilderError): """ super().__init__(message, code="IDEAL_ERR", details=details) +class BudgetHardCapExceeded(DeckBuilderError): + """Raised when a deck violates a hard budget cap. + + In hard mode, if the deck total exceeds ``budget_total`` after all + feasible replacements, this exception surfaces the violation so callers + can surface actionable messaging. + """ + + def __init__( + self, + total_price: float, + budget_total: float, + over_budget_cards: list | None = None, + details: dict | None = None, + ) -> None: + overage = round(total_price - budget_total, 2) + message = ( + f"Deck cost ${total_price:.2f} exceeds hard budget cap " + f"${budget_total:.2f} by ${overage:.2f}" + ) + super().__init__(message, code="BUDGET_HARD_CAP", details=details or {}) + self.total_price = total_price + self.budget_total = budget_total + self.overage = overage + self.over_budget_cards = over_budget_cards or [] + + class PriceConfigurationError(DeckBuilderError): """Raised when there are issues configuring price settings. diff --git a/code/file_setup/setup.py b/code/file_setup/setup.py index 62a8165..664e6de 100644 --- a/code/file_setup/setup.py +++ b/code/file_setup/setup.py @@ -410,3 +410,69 @@ def regenerate_processed_parquet() -> None: process_raw_parquet(raw_path, processed_path) logger.info(f"✓ Regenerated {processed_path}") + + +def refresh_prices_parquet(output_func=None) -> None: + """Rebuild the price cache from local Scryfall bulk data and write + ``price`` / ``price_updated`` columns into all_cards.parquet and + commander_cards.parquet. + + This is safe to call from both the web app and CLI contexts. + + Args: + output_func: Optional callable(str) for progress messages. Defaults + to the module logger. + """ + import datetime + from code.web.services.price_service import get_price_service + + _log = output_func or (lambda msg: logger.info(msg)) + + # Download a fresh copy of the Scryfall bulk data before rebuilding. + try: + from code.file_setup.scryfall_bulk_data import ScryfallBulkDataClient + from code.path_util import card_files_raw_dir + bulk_path = os.path.join(card_files_raw_dir(), "scryfall_bulk_data.json") + _log("Downloading fresh Scryfall bulk data …") + client = ScryfallBulkDataClient() + info = client.get_bulk_data_info() + client.download_bulk_data(info["download_uri"], bulk_path) + _log("Scryfall bulk data downloaded.") + except Exception as exc: + _log(f"Warning: Could not download fresh bulk data ({exc}). Using existing local copy.") + + _log("Rebuilding price cache from Scryfall bulk data …") + svc = get_price_service() + svc._rebuild_cache() + + processed_path = get_processed_cards_path() + if not os.path.exists(processed_path): + _log("No processed parquet found — run Setup first.") + return + + _log("Loading card database …") + df = pd.read_parquet(processed_path) + name_col = "faceName" if "faceName" in df.columns else "name" + card_names = df[name_col].fillna("").tolist() + + _log(f"Fetching prices for {len(card_names):,} cards …") + prices = svc.get_prices_batch(card_names) + priced = sum(1 for p in prices.values() if p is not None) + + now_iso = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + df["price"] = df[name_col].map(lambda n: prices.get(n) if n else None) + df["price_updated"] = now_iso + + loader = DataLoader() + loader.write_cards(df, processed_path) + _log(f"Updated all_cards.parquet — {priced:,} of {len(card_names):,} cards priced.") + + # Update commander_cards.parquet by applying the same price columns. + processed_dir = os.path.dirname(processed_path) + commander_path = os.path.join(processed_dir, "commander_cards.parquet") + if os.path.exists(commander_path) and "isCommander" in df.columns: + cmd_df = df[df["isCommander"] == True].copy() # noqa: E712 + loader.write_cards(cmd_df, commander_path) + _log(f"Updated commander_cards.parquet ({len(cmd_df):,} commanders).") + + _log("Price refresh complete.") diff --git a/code/settings.py b/code/settings.py index 7fca396..ed38c9e 100644 --- a/code/settings.py +++ b/code/settings.py @@ -158,6 +158,10 @@ SIMILARITY_CACHE_DOWNLOAD = os.getenv('SIMILARITY_CACHE_DOWNLOAD', '1').lower() # Batch build feature flag (Build X and Compare) ENABLE_BATCH_BUILD = os.getenv('ENABLE_BATCH_BUILD', '1').lower() not in ('0', 'false', 'off', 'disabled') +# M9: Stale price warnings — hours before a per-card price is considered stale. +# Set to 0 to disable stale indicators entirely. +PRICE_STALE_WARNING_HOURS: int = max(0, int(os.getenv('PRICE_STALE_WARNING_HOURS', '24'))) + # ---------------------------------------------------------------------------------- # THEME CATALOG SETTINGS # ---------------------------------------------------------------------------------- diff --git a/code/tests/test_budget_evaluator.py b/code/tests/test_budget_evaluator.py new file mode 100644 index 0000000..5f18ce0 --- /dev/null +++ b/code/tests/test_budget_evaluator.py @@ -0,0 +1,379 @@ +"""Tests for BudgetEvaluatorService - deck cost evaluation and alternatives.""" +from __future__ import annotations + +from typing import Dict, List, Optional +from unittest.mock import MagicMock, patch + +import pytest + +from code.web.services.budget_evaluator import BudgetEvaluatorService +from code.web.services.price_service import PriceService + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +def _make_price_service(prices: Dict[str, Optional[float]]) -> PriceService: + """Return a PriceService stub that returns predefined prices.""" + svc = MagicMock(spec=PriceService) + svc.get_price.side_effect = lambda name, region="usd", foil=False: prices.get(name) + svc.get_prices_batch.side_effect = lambda names, region="usd", foil=False: { + n: prices.get(n) for n in names + } + return svc + + +# Shared price table: {card_name: price} +KNOWN_PRICES: Dict[str, Optional[float]] = { + "Sol Ring": 2.00, + "Mana Crypt": 150.00, + "Lightning Bolt": 0.25, + "Arcane Signet": 1.50, + "Fellwar Stone": 1.00, + "Command Tower": 0.30, + "Swamp": 0.10, + "Island": 0.10, + "Craterhoof Behemoth": 30.00, + "Vampiric Tutor": 25.00, + "No Price Card": None, +} + + +@pytest.fixture +def price_svc(): + return _make_price_service(KNOWN_PRICES) + + +@pytest.fixture +def evaluator(price_svc): + return BudgetEvaluatorService(price_service=price_svc) + + +# --------------------------------------------------------------------------- +# Tests: evaluate_deck — basic cases +# --------------------------------------------------------------------------- + +def test_evaluate_under_budget(evaluator): + deck = ["Sol Ring", "Arcane Signet", "Command Tower"] + # 2.00 + 1.50 + 0.30 = 3.80 < 10.00 + report = evaluator.evaluate_deck(deck, budget_total=10.0) + assert report["budget_status"] == "under" + assert report["total_price"] == pytest.approx(3.80) + assert report["overage"] == 0.0 + + +def test_evaluate_soft_exceeded(evaluator): + deck = ["Sol Ring", "Mana Crypt", "Lightning Bolt"] + # 2.00 + 150.00 + 0.25 = 152.25 > 100.00 + report = evaluator.evaluate_deck(deck, budget_total=100.0, mode="soft") + assert report["budget_status"] == "soft_exceeded" + assert report["overage"] == pytest.approx(52.25) + + +def test_evaluate_hard_exceeded(evaluator): + deck = ["Sol Ring", "Mana Crypt"] + report = evaluator.evaluate_deck(deck, budget_total=50.0, mode="hard") + assert report["budget_status"] == "hard_exceeded" + + +def test_evaluate_empty_deck(evaluator): + report = evaluator.evaluate_deck([], budget_total=100.0) + assert report["total_price"] == 0.0 + assert report["budget_status"] == "under" + assert report["overage"] == 0.0 + + +# --------------------------------------------------------------------------- +# Tests: card_ceiling enforcement +# --------------------------------------------------------------------------- + +def test_card_ceiling_flags_expensive_card(evaluator): + deck = ["Sol Ring", "Mana Crypt", "Command Tower"] + report = evaluator.evaluate_deck(deck, budget_total=500.0, card_ceiling=10.0) + flagged = [e["card"] for e in report["over_budget_cards"]] + assert "Mana Crypt" in flagged + assert "Sol Ring" not in flagged + assert "Command Tower" not in flagged + + +def test_card_ceiling_not_triggered_under_cap(evaluator): + deck = ["Sol Ring", "Arcane Signet"] + report = evaluator.evaluate_deck(deck, budget_total=500.0, card_ceiling=5.0) + assert report["over_budget_cards"] == [] + + +# --------------------------------------------------------------------------- +# Tests: include_cards are exempt from over_budget flagging +# --------------------------------------------------------------------------- + +def test_include_cards_exempt_from_ceiling(evaluator): + deck = ["Mana Crypt", "Sol Ring"] + # Mana Crypt (150) is an include — should NOT appear in over_budget_cards + report = evaluator.evaluate_deck( + deck, budget_total=10.0, card_ceiling=10.0, include_cards=["Mana Crypt"] + ) + flagged = [e["card"] for e in report["over_budget_cards"]] + assert "Mana Crypt" not in flagged + + +def test_include_budget_overage_reported(evaluator): + deck = ["Craterhoof Behemoth", "Lightning Bolt"] + report = evaluator.evaluate_deck( + deck, budget_total=50.0, include_cards=["Craterhoof Behemoth"] + ) + assert report["include_budget_overage"] == pytest.approx(30.00) + + +def test_include_cards_counted_in_total_price(evaluator): + deck = ["Mana Crypt", "Sol Ring"] + report = evaluator.evaluate_deck(deck, budget_total=200.0, include_cards=["Mana Crypt"]) + assert report["total_price"] == pytest.approx(152.00) + + +# --------------------------------------------------------------------------- +# Tests: missing price handling (legacy_fail_open) +# --------------------------------------------------------------------------- + +def test_missing_price_fail_open_skips(evaluator): + deck = ["Sol Ring", "No Price Card"] + # No Price Card has no price → treated as 0 in calculation + report = evaluator.evaluate_deck(deck, budget_total=100.0, legacy_fail_open=True) + assert "No Price Card" in report["stale_prices"] + assert report["total_price"] == pytest.approx(2.00) + + +def test_missing_price_fail_closed_raises(evaluator): + deck = ["No Price Card"] + with pytest.raises(ValueError, match="No price data for"): + evaluator.evaluate_deck(deck, budget_total=100.0, legacy_fail_open=False) + + +# --------------------------------------------------------------------------- +# Tests: price_breakdown structure +# --------------------------------------------------------------------------- + +def test_price_breakdown_contains_all_cards(evaluator): + deck = ["Sol Ring", "Lightning Bolt", "Swamp"] + report = evaluator.evaluate_deck(deck, budget_total=100.0) + names_in_breakdown = [e["card"] for e in report["price_breakdown"]] + for card in deck: + assert card in names_in_breakdown + + +def test_price_breakdown_flags_include(evaluator): + deck = ["Mana Crypt", "Sol Ring"] + report = evaluator.evaluate_deck(deck, budget_total=200.0, include_cards=["Mana Crypt"]) + mc_entry = next(e for e in report["price_breakdown"] if e["card"] == "Mana Crypt") + assert mc_entry["is_include"] is True + sr_entry = next(e for e in report["price_breakdown"] if e["card"] == "Sol Ring") + assert sr_entry["is_include"] is False + + +# --------------------------------------------------------------------------- +# Tests: find_cheaper_alternatives +# --------------------------------------------------------------------------- + +def test_cheaper_alternatives_respects_max_price(): + """Alternatives returned must all be ≤ max_price.""" + # Build a card index stub with two alternatives + candidate_index = { + "ramp": [ + {"name": "Arcane Signet", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""}, + {"name": "Cursed Mirror", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""}, + ] + } + prices = {"Arcane Signet": 1.50, "Cursed Mirror": 8.00} + + svc = _make_price_service(prices) + evaluator = BudgetEvaluatorService(price_service=svc) + + with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \ + patch("code.web.services.card_index.maybe_build_index"): + mock_pool.side_effect = lambda tag: candidate_index.get(tag, []) + results = evaluator.find_cheaper_alternatives("Mana Crypt", max_price=5.0, tags=["ramp"]) + + # Only Arcane Signet (1.50) should qualify; Cursed Mirror (8.00) exceeds max_price + names = [r["name"] for r in results] + assert "Arcane Signet" in names + assert "Cursed Mirror" not in names + + +def test_cheaper_alternatives_sorted_by_price(): + """Alternatives should be sorted cheapest first.""" + candidates = [ + {"name": "Card A", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""}, + {"name": "Card B", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""}, + {"name": "Card C", "tags": ["ramp"], "color_identity": "", "color_identity_list": [], "mana_cost": "", "rarity": ""}, + ] + prices = {"Card A": 3.00, "Card B": 1.00, "Card C": 2.00} + svc = _make_price_service(prices) + evaluator = BudgetEvaluatorService(price_service=svc) + + with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \ + patch("code.web.services.card_index.maybe_build_index"): + mock_pool.return_value = candidates + results = evaluator.find_cheaper_alternatives("Mana Crypt", max_price=10.0, tags=["ramp"]) + + assert [r["name"] for r in results] == ["Card B", "Card C", "Card A"] + + +def test_cheaper_alternatives_empty_when_no_tags(): + evaluator = BudgetEvaluatorService(price_service=_make_price_service({})) + with patch("code.web.services.card_index.maybe_build_index"), \ + patch("code.web.services.card_index._CARD_INDEX", {}): + results = evaluator.find_cheaper_alternatives("Unknown Card", max_price=10.0) + assert results == [] + + +def test_cheaper_alternatives_color_identity_filter(): + """Cards outside the commander's color identity must be excluded.""" + candidates = [ + # This card requires White (W) — not in Dimir (U/B) + {"name": "Swords to Plowshares", "tags": ["removal"], "color_identity": "W", "color_identity_list": ["W"], "mana_cost": "{W}", "rarity": ""}, + {"name": "Doom Blade", "tags": ["removal"], "color_identity": "B", "color_identity_list": ["B"], "mana_cost": "{1}{B}", "rarity": ""}, + ] + prices = {"Swords to Plowshares": 1.00, "Doom Blade": 0.50} + svc = _make_price_service(prices) + evaluator = BudgetEvaluatorService(price_service=svc) + + with patch("code.web.services.card_index.get_tag_pool") as mock_pool, \ + patch("code.web.services.card_index.maybe_build_index"): + mock_pool.return_value = candidates + results = evaluator.find_cheaper_alternatives( + "Vampiric Tutor", max_price=5.0, + color_identity=["U", "B"], tags=["removal"] + ) + + names = [r["name"] for r in results] + assert "Swords to Plowshares" not in names + assert "Doom Blade" in names + + +# --------------------------------------------------------------------------- +# Tests: calculate_tier_ceilings +# --------------------------------------------------------------------------- + +def test_tier_ceilings_correct_fractions(evaluator): + ceilings = evaluator.calculate_tier_ceilings(100.0) + assert ceilings["S"] == pytest.approx(20.0) + assert ceilings["M"] == pytest.approx(10.0) + assert ceilings["L"] == pytest.approx(5.0) + + +def test_tier_ceilings_zero_budget(evaluator): + ceilings = evaluator.calculate_tier_ceilings(0.0) + assert all(v == 0.0 for v in ceilings.values()) + + +# --------------------------------------------------------------------------- +# Tests: validation guards +# --------------------------------------------------------------------------- + +def test_negative_budget_raises(evaluator): + with pytest.raises(Exception): + evaluator.evaluate_deck(["Sol Ring"], budget_total=-1.0) + + +def test_invalid_mode_raises(evaluator): + with pytest.raises(Exception): + evaluator.evaluate_deck(["Sol Ring"], budget_total=100.0, mode="turbo") + + +def test_negative_ceiling_raises(evaluator): + with pytest.raises(Exception): + evaluator.evaluate_deck(["Sol Ring"], budget_total=100.0, card_ceiling=-5.0) + + +# --------------------------------------------------------------------------- +# Tests: BudgetHardCapExceeded exception +# --------------------------------------------------------------------------- + +def test_budget_hard_cap_exception_attributes(): + from code.exceptions import BudgetHardCapExceeded + exc = BudgetHardCapExceeded( + total_price=200.0, + budget_total=150.0, + over_budget_cards=[{"card": "Mana Crypt", "price": 150.0}], + ) + assert exc.overage == pytest.approx(50.0) + assert exc.total_price == 200.0 + assert exc.budget_total == 150.0 + assert len(exc.over_budget_cards) == 1 + assert "BUDGET_HARD_CAP" in exc.code + + +# --------------------------------------------------------------------------- +# M8: Price chart helpers +# --------------------------------------------------------------------------- + +from code.web.services.budget_evaluator import ( + compute_price_category_breakdown, + compute_price_histogram, + CATEGORY_ORDER, +) + + +def test_category_breakdown_basic(): + items = [ + {"card": "Sol Ring", "price": 2.00, "tags": ["ramp", "mana rock"]}, + {"card": "Arcane Signet","price": 1.50, "tags": ["ramp", "mana rock"]}, + {"card": "Swords to Plowshares", "price": 3.00, "tags": ["spot removal", "removal"]}, + {"card": "Forest", "price": 0.25, "tags": ["land"]}, + ] + result = compute_price_category_breakdown(items) + assert result["totals"]["Ramp"] == pytest.approx(3.50) + assert result["totals"]["Removal"] == pytest.approx(3.00) + assert result["totals"]["Land"] == pytest.approx(0.25) + assert result["total"] == pytest.approx(6.75) + assert result["order"] == CATEGORY_ORDER + + +def test_category_breakdown_unmatched_goes_to_other(): + items = [{"card": "Thassa's Oracle", "price": 10.00, "tags": ["combo", "wincon"]}] + result = compute_price_category_breakdown(items) + assert result["totals"]["Synergy"] == pytest.approx(10.00) + # Specifically "combo" hits Synergy, not Other + + +def test_category_breakdown_no_price_skipped(): + items = [ + {"card": "Card A", "price": None, "tags": ["ramp"]}, + {"card": "Card B", "price": 5.00, "tags": []}, + ] + result = compute_price_category_breakdown(items) + assert result["total"] == pytest.approx(5.00) + assert result["totals"]["Other"] == pytest.approx(5.00) + + +def test_histogram_10_bins(): + items = [{"card": f"Card {i}", "price": float(i)} for i in range(1, 21)] + bins = compute_price_histogram(items) + assert len(bins) == 10 + assert all("label" in b and "count" in b and "pct" in b and "color" in b for b in bins) + assert sum(b["count"] for b in bins) == 20 + + +def test_histogram_all_same_price(): + items = [{"card": f"Card {i}", "price": 1.00} for i in range(5)] + bins = compute_price_histogram(items) + assert len(bins) == 10 + assert bins[0]["count"] == 5 + assert all(b["count"] == 0 for b in bins[1:]) + + +def test_histogram_fewer_than_2_returns_empty(): + assert compute_price_histogram([]) == [] + assert compute_price_histogram([{"card": "Solo", "price": 5.0}]) == [] + + +def test_histogram_excludes_unpriced_cards(): + items = [ + {"card": "A", "price": 1.0}, + {"card": "B", "price": None}, + {"card": "C", "price": 3.0}, + {"card": "D", "price": 5.0}, + ] + bins = compute_price_histogram(items) + assert sum(b["count"] for b in bins) == 3 # B excluded + diff --git a/code/tests/test_export_metadata_comprehensive.py b/code/tests/test_export_metadata_comprehensive.py index 675edbb..82c3188 100644 --- a/code/tests/test_export_metadata_comprehensive.py +++ b/code/tests/test_export_metadata_comprehensive.py @@ -8,7 +8,7 @@ This file consolidates tests from three source files: Created: 2026-02-20 Consolidation Purpose: Centralize all export and metadata-related tests -Total Tests: 21 (4 commander metadata + 2 MDFC + 15 metadata partition) +Total Tests: 25 (4 commander metadata + 2 MDFC + 15 metadata partition + 4 price column) """ from __future__ import annotations @@ -502,5 +502,110 @@ class TestCSVCompatibility: assert df_partitioned.loc[0, 'metadataTags'] == ['Applied: Cost Reduction'] +# ============================================================================ +# SECTION 5: PRICE COLUMN EXPORT TESTS (M7) +# Tests for price data in CSV exports +# ============================================================================ + +class _PriceBuilder(ReportingMixin): + """Minimal builder with 3 cards for price export tests.""" + + def __init__(self) -> None: + self.card_library = { + "Sol Ring": {"Card Type": "Artifact", "Count": 1, "Mana Cost": "{1}", "Mana Value": "1", "Role": "Ramp", "Tags": []}, + "Counterspell": {"Card Type": "Instant", "Count": 1, "Mana Cost": "{U}{U}", "Mana Value": "2", "Role": "Removal", "Tags": []}, + "Tropical Island": {"Card Type": "Land", "Count": 1, "Mana Cost": "", "Mana Value": "0", "Role": "Land", "Tags": []}, + } + self.output_func = lambda *_args, **_kwargs: None + self.commander_name = "" + self.primary_tag = "Spellslinger" + self.secondary_tag = None + self.tertiary_tag = None + self.selected_tags = ["Spellslinger"] + self.custom_export_base = "price_test" + + +def _suppress_cm(monkeypatch: pytest.MonkeyPatch) -> None: + stub = types.ModuleType("deck_builder.builder_utils") + stub.compute_color_source_matrix = lambda *_a, **_kw: {} + stub.multi_face_land_info = lambda *_a, **_kw: {} + monkeypatch.setitem(sys.modules, "deck_builder.builder_utils", stub) + + +def test_csv_price_column_present_no_lookup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Price column exists even when no price_lookup is provided; values are blank.""" + _suppress_cm(monkeypatch) + b = _PriceBuilder() + path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv")) + with path.open("r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + assert "Price" in (reader.fieldnames or []) + rows = list(reader) + card_rows = [r for r in rows if r["Name"] and r["Name"] != "Total"] + assert all(r["Price"] == "" for r in card_rows) + # No summary row when no prices available + assert not any(r["Name"] == "Total" for r in rows) + + +def test_csv_price_column_populated_by_lookup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Price column is filled when a price_lookup callable is supplied.""" + _suppress_cm(monkeypatch) + prices = {"Sol Ring": 1.50, "Counterspell": 2.00, "Tropical Island": 100.00} + + def _lookup(names: list) -> dict: + return {n: prices.get(n) for n in names} + + b = _PriceBuilder() + path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup)) + with path.open("r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + rows = list(reader) + + card_rows = {r["Name"]: r for r in rows if r["Name"] and r["Name"] != "Total"} + assert card_rows["Sol Ring"]["Price"] == "1.50" + assert card_rows["Counterspell"]["Price"] == "2.00" + assert card_rows["Tropical Island"]["Price"] == "100.00" + + +def test_csv_price_summary_row(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A Total row appears with the deck's summed price when prices are available.""" + _suppress_cm(monkeypatch) + + def _lookup(names: list) -> dict: + return {"Sol Ring": 1.50, "Counterspell": 2.00, "Tropical Island": 100.00} + + b = _PriceBuilder() + path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup)) + with path.open("r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + rows = list(reader) + + total_rows = [r for r in rows if r["Name"] == "Total"] + assert len(total_rows) == 1 + assert total_rows[0]["Price"] == "103.50" + + +def test_csv_price_blank_when_lookup_returns_none(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Cards with no price in the lookup get a blank Price cell, not an error.""" + _suppress_cm(monkeypatch) + + def _lookup(names: list) -> dict: + return {"Sol Ring": 1.50, "Counterspell": None, "Tropical Island": None} + + b = _PriceBuilder() + path = Path(b.export_decklist_csv(directory=str(tmp_path), filename="deck.csv", price_lookup=_lookup)) + with path.open("r", encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + rows = list(reader) + + card_rows = {r["Name"]: r for r in rows if r["Name"] and r["Name"] != "Total"} + assert card_rows["Sol Ring"]["Price"] == "1.50" + assert card_rows["Counterspell"]["Price"] == "" + assert card_rows["Tropical Island"]["Price"] == "" + # Total reflects only priced cards + total_rows = [r for r in rows if r["Name"] == "Total"] + assert total_rows[0]["Price"] == "1.50" + + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/code/tests/test_price_service.py b/code/tests/test_price_service.py new file mode 100644 index 0000000..d56cb1b --- /dev/null +++ b/code/tests/test_price_service.py @@ -0,0 +1,307 @@ +"""Tests for PriceService - price lookup, caching, and batch operations.""" +from __future__ import annotations + +import json +import os +import time +import threading +from typing import Any, Dict +from unittest.mock import patch, MagicMock + +import pytest + +from code.web.services.price_service import PriceService + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _make_bulk_line(name: str, usd: str = None, eur: str = None, usd_foil: str = None) -> str: + """Return a JSON line matching Scryfall bulk data format.""" + card: Dict[str, Any] = { + "object": "card", + "name": name, + "prices": { + "usd": usd, + "usd_foil": usd_foil, + "eur": eur, + "eur_foil": None, + "tix": None, + }, + } + return json.dumps(card) + + +def _write_bulk_data(path: str, cards: list) -> None: + """Write a minimal Scryfall bulk data JSON array (one card per line).""" + with open(path, "w", encoding="utf-8") as fh: + fh.write("[\n") + for i, card in enumerate(cards): + suffix = "," if i < len(cards) - 1 else "" + fh.write(json.dumps(card) + suffix + "\n") + fh.write("]\n") + + +@pytest.fixture +def bulk_data_file(tmp_path): + """Minimal Scryfall bulk data with known prices.""" + cards = [ + {"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.50", "usd_foil": "2.00", "eur": "0.40", "eur_foil": None, "tix": None}}, + {"object": "card", "name": "Sol Ring", "prices": {"usd": "2.00", "usd_foil": "5.00", "eur": "1.80", "eur_foil": None, "tix": None}}, + {"object": "card", "name": "Mana Crypt", "prices": {"usd": "150.00", "usd_foil": "300.00", "eur": "120.00", "eur_foil": None, "tix": None}}, + # Card with no price + {"object": "card", "name": "Unpriced Card", "prices": {"usd": None, "usd_foil": None, "eur": None, "eur_foil": None, "tix": None}}, + # Second printing of Lightning Bolt (cheaper) + {"object": "card", "name": "Lightning Bolt", "prices": {"usd": "0.25", "usd_foil": "1.00", "eur": "0.20", "eur_foil": None, "tix": None}}, + # DFC card + {"object": "card", "name": "Delver of Secrets // Insectile Aberration", "prices": {"usd": "1.50", "usd_foil": "8.00", "eur": "1.20", "eur_foil": None, "tix": None}}, + ] + path = str(tmp_path / "scryfall_bulk_data.json") + _write_bulk_data(path, cards) + return path + + +@pytest.fixture +def price_svc(bulk_data_file, tmp_path): + """PriceService pointed at the test bulk data, with a temporary cache path.""" + cache_path = str(tmp_path / "prices_cache.json") + return PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600) + + +# --------------------------------------------------------------------------- +# Tests: single price lookup +# --------------------------------------------------------------------------- + +def test_get_price_known_card(price_svc): + price = price_svc.get_price("Lightning Bolt") + # Should return the cheapest printing (0.25, not 0.50) + assert price == pytest.approx(0.25) + + +def test_get_price_case_insensitive(price_svc): + assert price_svc.get_price("lightning bolt") == price_svc.get_price("LIGHTNING BOLT") + + +def test_get_price_foil(price_svc): + foil = price_svc.get_price("Lightning Bolt", foil=True) + # Cheapest foil printing + assert foil == pytest.approx(1.00) + + +def test_get_price_eur_region(price_svc): + price = price_svc.get_price("Sol Ring", region="eur") + assert price == pytest.approx(1.80) + + +def test_get_price_unknown_card_returns_none(price_svc): + assert price_svc.get_price("Nonexistent Card Name XYZ") is None + + +def test_get_price_unpriced_card_returns_none(price_svc): + assert price_svc.get_price("Unpriced Card") is None + + +def test_get_price_expensive_card(price_svc): + assert price_svc.get_price("Mana Crypt") == pytest.approx(150.00) + + +# --------------------------------------------------------------------------- +# Tests: DFC card name indexing +# --------------------------------------------------------------------------- + +def test_get_price_dfc_combined_name(price_svc): + price = price_svc.get_price("Delver of Secrets // Insectile Aberration") + assert price == pytest.approx(1.50) + + +def test_get_price_dfc_front_face_name(price_svc): + """Front face name alone should resolve to the DFC price.""" + price = price_svc.get_price("Delver of Secrets") + assert price == pytest.approx(1.50) + + +def test_get_price_dfc_back_face_name(price_svc): + """Back face name alone should also resolve.""" + price = price_svc.get_price("Insectile Aberration") + assert price == pytest.approx(1.50) + + +# --------------------------------------------------------------------------- +# Tests: batch lookup +# --------------------------------------------------------------------------- + +def test_get_prices_batch_all_found(price_svc): + result = price_svc.get_prices_batch(["Lightning Bolt", "Sol Ring"]) + assert result["Lightning Bolt"] == pytest.approx(0.25) + assert result["Sol Ring"] == pytest.approx(2.00) + + +def test_get_prices_batch_mixed_found_missing(price_svc): + result = price_svc.get_prices_batch(["Lightning Bolt", "Unknown Card"]) + assert result["Lightning Bolt"] is not None + assert result["Unknown Card"] is None + + +def test_get_prices_batch_empty_list(price_svc): + assert price_svc.get_prices_batch([]) == {} + + +def test_get_prices_batch_preserves_original_case(price_svc): + result = price_svc.get_prices_batch(["LIGHTNING BOLT"]) + # Key should match input case exactly + assert "LIGHTNING BOLT" in result + assert result["LIGHTNING BOLT"] == pytest.approx(0.25) + + +# --------------------------------------------------------------------------- +# Tests: cache persistence +# --------------------------------------------------------------------------- + +def test_rebuild_writes_cache_file(price_svc, tmp_path): + # Trigger load → rebuild + price_svc.get_price("Sol Ring") + assert os.path.exists(price_svc._cache_path) + + +def test_cache_file_has_expected_structure(price_svc, tmp_path): + price_svc.get_price("Sol Ring") + with open(price_svc._cache_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + assert "prices" in data + assert "built_at" in data + assert "sol ring" in data["prices"] + + +def test_fresh_cache_loaded_without_rebuild(bulk_data_file, tmp_path): + """Second PriceService instance should load from cache, not rebuild.""" + cache_path = str(tmp_path / "prices_cache.json") + + svc1 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path) + svc1.get_price("Sol Ring") # triggers rebuild → writes cache + + rebuild_calls = [] + svc2 = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path) + orig_rebuild = svc2._rebuild_cache + + def patched_rebuild(): + rebuild_calls.append(1) + orig_rebuild() + + svc2._rebuild_cache = patched_rebuild + svc2.get_price("Sol Ring") # should load from cache, not rebuild + + assert rebuild_calls == [], "Second instance should not rebuild when cache is fresh" + + +def test_stale_cache_triggers_rebuild(bulk_data_file, tmp_path): + """Cache older than TTL should trigger a rebuild.""" + cache_path = str(tmp_path / "prices_cache.json") + + # Write a valid but stale cache file + stale_data = { + "prices": {"sol ring": {"usd": 2.0}}, + "built_at": time.time() - 99999, # very old + } + with open(cache_path, "w") as fh: + json.dump(stale_data, fh) + # Set mtime to old as well + old_time = time.time() - 99999 + os.utime(cache_path, (old_time, old_time)) + + rebuild_calls = [] + svc = PriceService(bulk_data_path=bulk_data_file, cache_path=cache_path, cache_ttl=3600) + orig_rebuild = svc._rebuild_cache + + def patched_rebuild(): + rebuild_calls.append(1) + orig_rebuild() + + svc._rebuild_cache = patched_rebuild + svc.get_price("Sol Ring") + + assert rebuild_calls == [1], "Stale cache should trigger a rebuild" + + +# --------------------------------------------------------------------------- +# Tests: cache stats / telemetry +# --------------------------------------------------------------------------- + +def test_cache_stats_structure(price_svc): + price_svc.get_price("Sol Ring") + stats = price_svc.cache_stats() + assert "total_entries" in stats + assert "hit_count" in stats + assert "miss_count" in stats + assert "hit_rate" in stats + assert "loaded" in stats + assert stats["loaded"] is True + + +def test_cache_stats_hit_miss_counts(price_svc): + price_svc.get_price("Sol Ring") # hit + price_svc.get_price("Unknown Card") # miss + stats = price_svc.cache_stats() + assert stats["hit_count"] >= 1 + assert stats["miss_count"] >= 1 + + +def test_cache_stats_hit_rate_zero_before_load(): + """Before any lookups, hit_rate should be 0.""" + svc = PriceService(bulk_data_path="/nonexistent", cache_path="/nonexistent/cache.json") + # Don't trigger _ensure_loaded - call cache_stats indirectly via a direct check + # We expect loaded=False and hit_rate=0 + # Note: cache_stats calls _ensure_loaded, so bulk_data missing → cache remains empty + stats = svc.cache_stats() + assert stats["hit_rate"] == 0.0 + + +# --------------------------------------------------------------------------- +# Tests: background refresh +# --------------------------------------------------------------------------- + +def test_refresh_cache_background_starts_thread(price_svc): + price_svc.get_price("Sol Ring") # ensure loaded + price_svc.refresh_cache_background() + # Allow thread to start + time.sleep(0.05) + # Thread should have run (or be running) + assert price_svc._refresh_thread is not None + + +def test_refresh_cache_background_no_duplicate_threads(price_svc): + price_svc.get_price("Sol Ring") + price_svc.refresh_cache_background() + t1 = price_svc._refresh_thread + price_svc.refresh_cache_background() # second call while thread running + t2 = price_svc._refresh_thread + assert t1 is t2, "Should not spawn a second refresh thread" + + +# --------------------------------------------------------------------------- +# Tests: missing / corrupted bulk data +# --------------------------------------------------------------------------- + +def test_missing_bulk_data_returns_none(tmp_path): + svc = PriceService( + bulk_data_path=str(tmp_path / "nonexistent.json"), + cache_path=str(tmp_path / "cache.json"), + ) + assert svc.get_price("Sol Ring") is None + + +def test_corrupted_bulk_data_line_skipped(tmp_path): + """Malformed JSON lines should be skipped without crashing.""" + bulk_path = str(tmp_path / "bulk.json") + with open(bulk_path, "w") as fh: + fh.write("[\n") + fh.write('{"object":"card","name":"Sol Ring","prices":{"usd":"2.00","usd_foil":null,"eur":null,"eur_foil":null,"tix":null}}\n') + fh.write("NOT VALID JSON,,,,\n") + fh.write("]") + + svc = PriceService( + bulk_data_path=bulk_path, + cache_path=str(tmp_path / "cache.json"), + ) + # Should still find Sol Ring despite corrupted line + assert svc.get_price("Sol Ring") == pytest.approx(2.00) diff --git a/code/web/app.py b/code/web/app.py index 6eca1a7..534d7ec 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -82,6 +82,21 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s) except Exception: pass + # Start price auto-refresh scheduler (optional, 1 AM UTC daily) + if PRICE_AUTO_REFRESH: + try: + from .services.price_service import get_price_service + from code.file_setup.setup import refresh_prices_parquet + get_price_service().start_daily_refresh(hour=1, on_after_rebuild=refresh_prices_parquet) + except Exception: + pass + # Start lazy per-card price refresh worker (optional) + if PRICE_LAZY_REFRESH: + try: + from .services.price_service import get_price_service + get_price_service().start_lazy_refresh(stale_days=7) + except Exception: + pass yield # (no shutdown tasks currently) @@ -104,6 +119,16 @@ if _STATIC_DIR.exists(): # Jinja templates templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) +# Expose price cache timestamp as a Jinja2 global callable (evaluated per-render) +def _price_cache_built_at() -> "str | None": + try: + from .services.price_service import get_price_service + return get_price_service().get_cache_built_at() + except Exception: + return None + +templates.env.globals["_price_cache_ts"] = _price_cache_built_at + # Add custom Jinja2 filter for card image URLs def card_image_url(card_name: str, size: str = "normal") -> str: """ @@ -178,6 +203,9 @@ 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_BUDGET_MODE = _as_bool(os.getenv("ENABLE_BUDGET_MODE"), True) +PRICE_AUTO_REFRESH = _as_bool(os.getenv("PRICE_AUTO_REFRESH"), False) +PRICE_LAZY_REFRESH = _as_bool(os.getenv("PRICE_LAZY_REFRESH"), True) ENABLE_CUSTOM_THEMES = _as_bool(os.getenv("ENABLE_CUSTOM_THEMES"), True) SHOW_THEME_QUALITY_BADGES = _as_bool(os.getenv("SHOW_THEME_QUALITY_BADGES"), True) SHOW_THEME_POOL_BADGES = _as_bool(os.getenv("SHOW_THEME_POOL_BADGES"), True) @@ -2312,6 +2340,7 @@ from .routes import cards as cards_routes # noqa: E402 from .routes import card_browser as card_browser_routes # noqa: E402 from .routes import compare as compare_routes # noqa: E402 from .routes import api as api_routes # noqa: E402 +from .routes import price as price_routes # noqa: E402 app.include_router(build_routes.router) app.include_router(build_validation_routes.router, prefix="/build") app.include_router(build_multicopy_routes.router, prefix="/build") @@ -2334,6 +2363,7 @@ app.include_router(cards_routes.router) app.include_router(card_browser_routes.router) app.include_router(compare_routes.router) app.include_router(api_routes.router) +app.include_router(price_routes.router) # Warm validation cache early to reduce first-call latency in tests and dev try: diff --git a/code/web/routes/build_alternatives.py b/code/web/routes/build_alternatives.py index 4c7a651..c3ce019 100644 --- a/code/web/routes/build_alternatives.py +++ b/code/web/routes/build_alternatives.py @@ -243,6 +243,20 @@ async def build_alternatives( return HTMLResponse(cached) def _render_and_cache(_items: list[dict]): + # Enrich each item with USD price from PriceService (best-effort) + try: + from ..services.price_service import get_price_service + _svc = get_price_service() + _svc._ensure_loaded() + _prices = _svc._cache # keyed by lowercase card name → {usd, usd_foil, ...} + for _it in _items: + if not _it.get("price"): + _nm = (_it.get("name_lower") or str(_it.get("name", ""))).lower() + _entry = _prices.get(_nm) + if _entry: + _it["price"] = _entry.get("usd") + except Exception: + pass html_str = templates.get_template("build/_alternatives.html").render({ "request": request, "name": name_disp, diff --git a/code/web/routes/build_newflow.py b/code/web/routes/build_newflow.py index d1d3725..a6b38dc 100644 --- a/code/web/routes/build_newflow.py +++ b/code/web/routes/build_newflow.py @@ -20,6 +20,7 @@ from ..app import ( ENABLE_BATCH_BUILD, DEFAULT_THEME_MATCH_MODE, THEME_POOL_SECTIONS, + ENABLE_BUDGET_MODE, ) from ..services.build_utils import ( step5_ctx_from_result, @@ -113,6 +114,7 @@ async def build_new_modal(request: Request) -> HTMLResponse: "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "enable_budget_mode": ENABLE_BUDGET_MODE, "ideals_ui_mode": WEB_IDEALS_UI, # 'input' or 'slider' "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": { @@ -425,6 +427,10 @@ async def build_new_submit( enforcement_mode: str = Form("warn"), allow_illegal: bool = Form(False), fuzzy_matching: bool = Form(True), + # Budget (optional) + budget_total: float | None = Form(None), + card_ceiling: float | None = Form(None), + pool_tolerance: str | None = Form(None), # percent string; blank/None treated as 0% (hard cap at ceiling) # Build count for multi-build build_count: int = Form(1), # Quick Build flag @@ -474,6 +480,9 @@ async def build_new_submit( "partner_enabled": partner_form_state["partner_enabled"], "secondary_commander": partner_form_state["secondary_commander"], "background": partner_form_state["background"], + "budget_total": budget_total, + "card_ceiling": card_ceiling, + "pool_tolerance": pool_tolerance if pool_tolerance is not None else "15", } commander_detail = lookup_commander_detail(commander) @@ -501,6 +510,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "enable_budget_mode": ENABLE_BUDGET_MODE, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(suggested), "tag_slot_html": None, @@ -527,6 +537,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "enable_budget_mode": ENABLE_BUDGET_MODE, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(commander), "tag_slot_html": None, @@ -633,6 +644,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "enable_budget_mode": ENABLE_BUDGET_MODE, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(primary_commander_name), "tag_slot_html": tag_slot_html, @@ -773,6 +785,7 @@ async def build_new_submit( "show_must_have_buttons": SHOW_MUST_HAVE_BUTTONS, "enable_custom_themes": ENABLE_CUSTOM_THEMES, "enable_batch_build": ENABLE_BATCH_BUILD, + "enable_budget_mode": ENABLE_BUDGET_MODE, "multi_copy_archetypes_js": _ARCHETYPE_JS_MAP, "form": _form_state(sess.get("commander", "")), "tag_slot_html": None, @@ -906,7 +919,31 @@ async def build_new_submit( # If exclude parsing fails, log but don't block the build import logging logging.warning(f"Failed to parse exclude cards: {e}") - + + # Store budget config in session (only when a total is provided) + try: + if ENABLE_BUDGET_MODE and budget_total and float(budget_total) > 0: + budget_cfg: dict = {"total": float(budget_total), "mode": "soft"} + if card_ceiling and float(card_ceiling) > 0: + budget_cfg["card_ceiling"] = float(card_ceiling) + # pool_tolerance: blank/None → 0.0 (hard cap at ceiling); digit string → float (e.g. "15" → 0.15) + # Absence of key in budget_cfg means M4 falls back to the env-level default. + _tol = (pool_tolerance or "").strip() + try: + budget_cfg["pool_tolerance"] = float(_tol) / 100.0 if _tol else 0.0 + except ValueError: + budget_cfg["pool_tolerance"] = 0.15 # bad input → safe default + sess["budget_config"] = budget_cfg + else: + sess.pop("budget_config", None) + except Exception: + sess.pop("budget_config", None) + + # Assign a stable build ID for this build run (used by JS to detect true new builds, + # distinct from per-stage summary_token which increments every stage) + import time as _time + sess["build_id"] = str(int(_time.time() * 1000)) + # Clear any old staged build context for k in ["build_ctx", "locks", "replace_mode"]: if k in sess: @@ -1285,9 +1322,9 @@ def quick_build_progress(request: Request): # Return Step 5 which will replace the whole wizard div response = templates.TemplateResponse("build/_step5.html", ctx) response.set_cookie("sid", sid, httponly=True, samesite="lax") - # Tell HTMX to target #wizard and swap outerHTML to replace the container + # Tell HTMX to target #wizard and swap innerHTML (keeps #wizard in DOM for subsequent interactions) response.headers["HX-Retarget"] = "#wizard" - response.headers["HX-Reswap"] = "outerHTML" + response.headers["HX-Reswap"] = "innerHTML" return response # Fallback if no result yet return HTMLResponse('Build complete. Please refresh.') @@ -1302,3 +1339,124 @@ def quick_build_progress(request: Request): response = templates.TemplateResponse("build/_quick_build_progress_content.html", ctx) response.set_cookie("sid", sid, httponly=True, samesite="lax") return response + + +# --------------------------------------------------------------------------- +# M5: Budget swap route — swap one card in the finalized deck and re-evaluate +# --------------------------------------------------------------------------- + +@router.post("/budget-swap", response_class=HTMLResponse) +async def budget_swap( + request: Request, + old_card: str = Form(...), + new_card: str = Form(...), +): + """Replace one card in the session budget snapshot and re-render the review panel.""" + sid = request.cookies.get("sid") or request.headers.get("X-Session-ID") + if not sid: + return HTMLResponse("") + sess = get_session(sid) + + snapshot: list[str] = list(sess.get("budget_deck_snapshot") or []) + if not snapshot: + return HTMLResponse("") + + old_lower = old_card.strip().lower() + new_name = new_card.strip() + replaced = False + for i, name in enumerate(snapshot): + if name.strip().lower() == old_lower: + snapshot[i] = new_name + replaced = True + break + if not replaced: + return HTMLResponse("") + sess["budget_deck_snapshot"] = snapshot + + # Re-evaluate budget + budget_cfg = sess.get("budget_config") or {} + try: + budget_total = float(budget_cfg.get("total") or 0) + except Exception: + budget_total = 0.0 + if budget_total <= 0: + return HTMLResponse("") + + budget_mode = str(budget_cfg.get("mode", "soft")).strip().lower() + try: + card_ceiling = float(budget_cfg.get("card_ceiling")) if budget_cfg.get("card_ceiling") else None + except Exception: + card_ceiling = None + include_cards = [str(c).strip() for c in (sess.get("include_cards") or []) if str(c).strip()] + color_identity: list[str] | None = None + try: + ci_raw = sess.get("color_identity") + if ci_raw and isinstance(ci_raw, list): + color_identity = [str(c).upper() for c in ci_raw] + except Exception: + pass + + try: + from ..services.budget_evaluator import BudgetEvaluatorService + svc = BudgetEvaluatorService() + report = svc.evaluate_deck( + snapshot, + budget_total, + mode=budget_mode, + card_ceiling=card_ceiling, + include_cards=include_cards, + color_identity=color_identity, + ) + except Exception: + return HTMLResponse("") + + total_price = float(report.get("total_price", 0.0)) + tolerance = bc.BUDGET_TOTAL_TOLERANCE + over_budget_review = total_price > budget_total * (1.0 + tolerance) + + overage_pct = round((total_price - budget_total) / budget_total * 100, 1) if (budget_total and over_budget_review) else 0.0 + include_set = {c.lower().strip() for c in include_cards} + + # Use price_breakdown sorted by price desc — most expensive cards contribute most to total overage + breakdown = report.get("price_breakdown") or [] + priced = sorted( + [e for e in breakdown + if not e.get("is_include") and (e.get("price") is not None) and float(e.get("price") or 0.0) > 0], + key=lambda x: -float(x.get("price") or 0.0), + ) + over_cards_out: list[dict] = [] + for entry in priced[:6]: + name = entry.get("card", "") + price = float(entry.get("price") or 0.0) + is_include = name.lower().strip() in include_set + try: + alts_raw = svc.find_cheaper_alternatives( + name, + max_price=max(0.0, price - 0.01), + region="usd", + color_identity=color_identity, + ) + except Exception: + alts_raw = [] + over_cards_out.append({ + "name": name, + "price": price, + "swap_disabled": is_include, + "alternatives": [ + {"name": a["name"], "price": a.get("price"), "shared_tags": a.get("shared_tags", [])} + for a in alts_raw[:3] + ], + }) + + ctx = { + "request": request, + "budget_review_visible": over_budget_review, + "over_budget_review": over_budget_review, + "budget_review_total": round(total_price, 2), + "budget_review_cap": round(budget_total, 2), + "budget_overage_pct": overage_pct, + "over_budget_cards": over_cards_out, + } + response = templates.TemplateResponse("build/_budget_review.html", ctx) + response.set_cookie("sid", sid, httponly=True, samesite="lax") + return response diff --git a/code/web/routes/build_wizard.py b/code/web/routes/build_wizard.py index 8885496..ffd40da 100644 --- a/code/web/routes/build_wizard.py +++ b/code/web/routes/build_wizard.py @@ -948,7 +948,10 @@ async def build_step5_start_get(request: Request) -> HTMLResponse: @router.post("/step5/start", response_class=HTMLResponse) async def build_step5_start(request: Request) -> HTMLResponse: - return RedirectResponse("/build", status_code=302) + """(Re)start the build from step 4 review page.""" + sid = request.cookies.get("sid") or new_sid() + sess = get_session(sid) + commander = sess.get("commander") if not commander: resp = templates.TemplateResponse( "build/_step1.html", @@ -957,7 +960,8 @@ async def build_step5_start(request: Request) -> HTMLResponse: resp.set_cookie("sid", sid, httponly=True, samesite="lax") return resp try: - # Initialize step-by-step build context and run first stage + import time as _time + sess["build_id"] = str(int(_time.time() * 1000)) sess["build_ctx"] = start_ctx_from_session(sess) show_skipped = False try: @@ -966,18 +970,15 @@ async def build_step5_start(request: Request) -> HTMLResponse: except Exception: pass res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped) - # Save summary to session for deck_summary partial to access if res.get("summary"): sess["summary"] = res["summary"] status = "Stage complete" if not res.get("done") else "Build complete" - # If Multi-Copy ran first, mark applied to prevent redundant rebuilds on Continue try: if res.get("label") == "Multi-Copy Package" and sess.get("multi_copy"): mc = sess.get("multi_copy") sess["mc_applied_key"] = f"{mc.get('id','')}|{int(mc.get('count',0))}|{1 if mc.get('thrumming') else 0}" except Exception: pass - # Note: no redirect; the inline compliance panel will render inside Step 5 sess["last_step"] = 5 ctx = step5_ctx_from_result(request, sess, res, status_text=status, show_skipped=show_skipped) resp = templates.TemplateResponse("build/_step5.html", ctx) @@ -985,14 +986,7 @@ async def build_step5_start(request: Request) -> HTMLResponse: _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 - err_ctx = step5_error_ctx( - request, - sess, - f"Failed to start build: {e}", - include_name=False, - ) - # Ensure commander stays visible if set + err_ctx = step5_error_ctx(request, sess, f"Failed to start build: {e}", include_name=False) err_ctx["commander"] = commander resp = templates.TemplateResponse("build/_step5.html", err_ctx) resp.set_cookie("sid", sid, httponly=True, samesite="lax") diff --git a/code/web/routes/decks.py b/code/web/routes/decks.py index 9b4f290..e6f5c32 100644 --- a/code/web/routes/decks.py +++ b/code/web/routes/decks.py @@ -1,15 +1,17 @@ from __future__ import annotations from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, Response from pathlib import Path import csv +import io import os from typing import Any, Dict, List, Optional, Tuple from ..app import templates from ..services.orchestrator import tags_for_commander from ..services.summary_utils import format_theme_label, format_theme_list, summary_ctx +from ..app import ENABLE_BUDGET_MODE router = APIRouter(prefix="/decks") @@ -402,6 +404,47 @@ async def decks_view(request: Request, name: str) -> HTMLResponse: "commander_role_label": format_theme_label("Commander"), } ) + + # Budget evaluation (only when budget_config is stored in the sidecar meta) + if ENABLE_BUDGET_MODE: + budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None + if isinstance(budget_config, dict) and budget_config.get("total"): + try: + from ..services.budget_evaluator import BudgetEvaluatorService + card_counts = _read_deck_counts(p) + decklist = list(card_counts.keys()) + color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None + include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else [] + svc = BudgetEvaluatorService() + budget_report = svc.evaluate_deck( + decklist=decklist, + budget_total=float(budget_config["total"]), + mode=str(budget_config.get("mode", "soft")), + card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None, + color_identity=color_identity, + include_cards=include_cards or None, + ) + ctx["budget_report"] = budget_report + ctx["budget_config"] = budget_config + # M8: Price charts + try: + from ..services.budget_evaluator import compute_price_category_breakdown, compute_price_histogram + _breakdown = budget_report.get("price_breakdown") or [] + _card_tags: Dict[str, List[str]] = {} + if isinstance(summary, dict): + _tb = (summary.get("type_breakdown") or {}).get("cards") or {} + for _clist in _tb.values(): + for _c in (_clist or []): + if isinstance(_c, dict) and _c.get("name"): + _card_tags[_c["name"]] = list(_c.get("tags") or []) + _enriched = [{**item, "tags": _card_tags.get(item.get("card", ""), [])} for item in _breakdown] + ctx["price_category_chart"] = compute_price_category_breakdown(_enriched) + ctx["price_histogram_chart"] = compute_price_histogram(_breakdown) + except Exception: + pass + except Exception: + pass + return templates.TemplateResponse("decks/view.html", ctx) @@ -486,3 +529,144 @@ async def decks_compare(request: Request, A: Optional[str] = None, B: Optional[s "metaB": metaB, }, ) + + +@router.get("/pickups", response_class=HTMLResponse) +async def decks_pickups(request: Request, name: str) -> HTMLResponse: + """Show the pickups list for a deck that was built with budget mode enabled.""" + base = _deck_dir() + p = (base / name).resolve() + if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"): + return templates.TemplateResponse( + "decks/index.html", + {"request": request, "items": _list_decks(), "error": "Deck not found."}, + ) + + meta_info: Dict[str, Any] = {} + commander_name = "" + sidecar = p.with_suffix(".summary.json") + if sidecar.exists(): + try: + import json as _json + payload = _json.loads(sidecar.read_text(encoding="utf-8")) + if isinstance(payload, dict): + meta_info = payload.get("meta") or {} + commander_name = meta_info.get("commander") or "" + except Exception: + pass + + budget_config = meta_info.get("budget_config") if isinstance(meta_info, dict) else None + budget_report = None + error_msg = None + + if not ENABLE_BUDGET_MODE: + error_msg = "Budget mode is not enabled (set ENABLE_BUDGET_MODE=1)." + elif not isinstance(budget_config, dict) or not budget_config.get("total"): + error_msg = "Budget mode was not enabled when this deck was built." + else: + try: + from ..services.budget_evaluator import BudgetEvaluatorService + card_counts = _read_deck_counts(p) + decklist = list(card_counts.keys()) + color_identity = meta_info.get("color_identity") if isinstance(meta_info, dict) else None + include_cards = list(meta_info.get("include_cards") or []) if isinstance(meta_info, dict) else [] + svc = BudgetEvaluatorService() + budget_report = svc.evaluate_deck( + decklist=decklist, + budget_total=float(budget_config["total"]), + mode=str(budget_config.get("mode", "soft")), + card_ceiling=float(budget_config["card_ceiling"]) if budget_config.get("card_ceiling") else None, + color_identity=color_identity, + include_cards=include_cards or None, + ) + except Exception as exc: + error_msg = f"Budget evaluation failed: {exc}" + + stale_prices: set[str] = set() + stale_prices_global = False + try: + from ..services.price_service import get_price_service + from code.settings import PRICE_STALE_WARNING_HOURS + _psvc = get_price_service() + _psvc._ensure_loaded() + if PRICE_STALE_WARNING_HOURS > 0: + _stale = _psvc.get_stale_cards(PRICE_STALE_WARNING_HOURS) + if _stale and len(_stale) > len(_psvc._cache) * 0.5: + stale_prices_global = True + else: + stale_prices = _stale + except Exception: + pass + + return templates.TemplateResponse( + "decks/pickups.html", + { + "request": request, + "name": p.name, + "commander": commander_name, + "budget_config": budget_config, + "budget_report": budget_report, + "error": error_msg, + "stale_prices": stale_prices, + "stale_prices_global": stale_prices_global, + }, + ) + + +@router.get("/download-csv") +async def decks_download_csv(name: str) -> Response: + """Serve a CSV export with live prices fetched at download time.""" + base = _deck_dir() + p = (base / name).resolve() + if not _safe_within(base, p) or not (p.exists() and p.is_file() and p.suffix.lower() == ".csv"): + return HTMLResponse("File not found", status_code=404) + try: + with p.open("r", encoding="utf-8", newline="") as f: + reader = csv.reader(f) + headers = next(reader, []) + data_rows = list(reader) + except Exception: + return HTMLResponse("Could not read CSV", status_code=500) + + # Strip any stale baked Price column + if "Price" in headers: + price_idx = headers.index("Price") + headers = [h for i, h in enumerate(headers) if i != price_idx] + data_rows = [[v for i, v in enumerate(row) if i != price_idx] for row in data_rows] + + name_idx = headers.index("Name") if "Name" in headers else 0 + card_names = [ + row[name_idx] for row in data_rows + if row and len(row) > name_idx and row[name_idx] and row[name_idx] != "Total" + ] + + prices_map: Dict[str, Any] = {} + try: + from ..services.price_service import get_price_service + prices_map = get_price_service().get_prices_batch(card_names) or {} + except Exception: + pass + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(headers + ["Price"]) + for row in data_rows: + if not row: + continue + name_val = row[name_idx] if len(row) > name_idx else "" + if name_val == "Total": + continue + price_val = prices_map.get(name_val) + writer.writerow(row + [f"{price_val:.2f}" if price_val is not None else ""]) + + if prices_map: + total = sum(v for v in prices_map.values() if v is not None) + empty = [""] * len(headers) + empty[name_idx] = "Total" + writer.writerow(empty + [f"{total:.2f}"]) + + return Response( + content=output.getvalue().encode("utf-8"), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{p.name}"'}, + ) diff --git a/code/web/routes/price.py b/code/web/routes/price.py new file mode 100644 index 0000000..3c041ad --- /dev/null +++ b/code/web/routes/price.py @@ -0,0 +1,111 @@ +"""Price API routes for card price lookups. + +Provides endpoints for single-card and batch price queries backed by +the PriceService (Scryfall bulk data + JSON cache). +""" +from __future__ import annotations + +import threading +from typing import List, Optional +from urllib.parse import unquote + +from fastapi import APIRouter, Body, Query +from fastapi.responses import JSONResponse + +from code.web.services.price_service import get_price_service +from code.web.decorators.telemetry import track_route_access, log_route_errors + +router = APIRouter(prefix="/api/price") + + +@router.get("/stats") +@track_route_access("price_cache_stats") +async def price_cache_stats(): + """Return cache telemetry for the PriceService.""" + svc = get_price_service() + return JSONResponse(svc.cache_stats()) + + +@router.post("/refresh") +@track_route_access("price_cache_refresh") +async def refresh_price_cache(): + """Trigger a background rebuild of the price cache and parquet price columns. + + Returns immediately — the rebuild runs in a daemon thread. + """ + def _run() -> None: + try: + from code.file_setup.setup import refresh_prices_parquet + refresh_prices_parquet() + except Exception as exc: + import logging + logging.getLogger(__name__).error("Manual price refresh failed: %s", exc) + + t = threading.Thread(target=_run, daemon=True, name="price-manual-refresh") + t.start() + return JSONResponse({"ok": True, "message": "Price cache refresh started in background."}) + + +@router.get("/{card_name:path}") +@track_route_access("price_lookup") +@log_route_errors("price_lookup") +async def get_card_price( + card_name: str, + region: str = Query("usd", pattern="^(usd|eur)$"), + foil: bool = Query(False), +): + """Look up the price for a single card. + + Args: + card_name: Card name (URL-encoded, case-insensitive). + region: Price region — ``usd`` or ``eur``. + foil: If true, return the foil price. + + Returns: + JSON with ``card_name``, ``price`` (float or null), ``region``, + ``foil``, ``found`` (bool). + """ + name = unquote(card_name).strip() + svc = get_price_service() + price = svc.get_price(name, region=region, foil=foil) + return JSONResponse({ + "card_name": name, + "price": price, + "region": region, + "foil": foil, + "found": price is not None, + }) + + +@router.post("/batch") +@track_route_access("price_batch_lookup") +@log_route_errors("price_batch_lookup") +async def get_prices_batch( + card_names: List[str] = Body(..., max_length=100), + region: str = Query("usd", pattern="^(usd|eur)$"), + foil: bool = Query(False), +): + """Look up prices for multiple cards in a single request. + + Request body: JSON array of card name strings (max 100). + + Args: + card_names: List of card names. + region: Price region — ``usd`` or ``eur``. + foil: If true, return foil prices. + + Returns: + JSON with ``prices`` (dict name→float|null) and ``missing`` (list + of names with no price data). + """ + svc = get_price_service() + prices = svc.get_prices_batch(card_names, region=region, foil=foil) + missing = [n for n, p in prices.items() if p is None] + return JSONResponse({ + "prices": prices, + "missing": missing, + "region": region, + "foil": foil, + "total": len(card_names), + "found": len(card_names) - len(missing), + }) diff --git a/code/web/routes/setup.py b/code/web/routes/setup.py index dc711d4..f2e58e3 100644 --- a/code/web/routes/setup.py +++ b/code/web/routes/setup.py @@ -196,10 +196,14 @@ async def download_github(): async def setup_index(request: Request) -> HTMLResponse: import code.settings as settings from code.file_setup.image_cache import ImageCache - + from code.web.services.price_service import get_price_service + from code.web.app import PRICE_AUTO_REFRESH + image_cache = ImageCache() return templates.TemplateResponse("setup/index.html", { "request": request, "similarity_enabled": settings.ENABLE_CARD_SIMILARITIES, - "image_cache_enabled": image_cache.is_enabled() + "image_cache_enabled": image_cache.is_enabled(), + "price_cache_built_at": get_price_service().get_cache_built_at(), + "price_auto_refresh": PRICE_AUTO_REFRESH, }) diff --git a/code/web/services/budget_evaluator.py b/code/web/services/budget_evaluator.py new file mode 100644 index 0000000..10d6a99 --- /dev/null +++ b/code/web/services/budget_evaluator.py @@ -0,0 +1,692 @@ +"""Budget evaluation service for deck cost analysis. + +Evaluates a deck against a budget constraint, identifies over-budget cards, +finds cheaper alternatives (same tags + color identity, lower price), and +produces a BudgetReport with replacements, per-card breakdown, and a +pickups list for targeted acquisition. + +Priority order (highest to lowest): + exclude > include > budget > bracket + +Include-list cards are never auto-replaced; their cost is reported separately +as ``include_budget_overage``. +""" +from __future__ import annotations + +import logging +import math +from typing import Any, Dict, List, Optional, Set + +from code.web.services.base import BaseService +from code.web.services.price_service import PriceService, get_price_service +from code import logging_util + +logger = logging_util.logging.getLogger(__name__) +logger.setLevel(logging_util.LOG_LEVEL) +logger.addHandler(logging_util.file_handler) +logger.addHandler(logging_util.stream_handler) + +# Splurge tier ceilings as a fraction of the total budget. +# S = top 20 %, M = top 10 %, L = top 5 % +_TIER_FRACTIONS = {"S": 0.20, "M": 0.10, "L": 0.05} + +# How many alternatives to return per card at most. +_MAX_ALTERNATIVES = 5 + +# Ordered broad MTG card types — first match wins for type detection. +_BROAD_TYPES = ("Land", "Creature", "Planeswalker", "Battle", "Enchantment", "Artifact", "Instant", "Sorcery") + +# M8: Build stage category order and tag patterns for category spend breakdown. +CATEGORY_ORDER = ["Land", "Ramp", "Creature", "Card Draw", "Removal", "Wipe", "Protection", "Synergy", "Other"] +_CATEGORY_COLORS: Dict[str, str] = { + "Land": "#94a3b8", + "Ramp": "#34d399", + "Creature": "#fb923c", + "Card Draw": "#60a5fa", + "Removal": "#f87171", + "Wipe": "#dc2626", + "Protection": "#06b6d4", + "Synergy": "#c084fc", + "Other": "#f59e0b", +} +# Creature is handled via broad_type fallback (after tag patterns), not listed here +_CATEGORY_PATTERNS: List[tuple] = [ + ("Land", ["land"]), + ("Ramp", ["ramp", "mana rock", "mana dork", "mana acceleration", "mana production"]), + ("Card Draw", ["card draw", "draw", "card advantage", "cantrip", "looting", "cycling"]), + ("Removal", ["removal", "spot removal", "bounce", "exile"]), + ("Wipe", ["board wipe", "sweeper", "wrath"]), + ("Protection", ["protection", "counterspell", "hexproof", "shroud", "indestructible", "ward"]), + ("Synergy", ["synergy", "combo", "payoff", "enabler"]), +] +def _fmt_price_label(price: float) -> str: + """Short x-axis label for a histogram bin boundary.""" + if price <= 0: + return "$0" + if price < 1.0: + return f"${price:.2f}" + if price < 10.0: + return f"${price:.1f}" if price != int(price) else f"${int(price)}" + return f"${price:.0f}" + + +# Basic land names excluded from price histogram (their prices are ~$0 and skew the chart) +_BASIC_LANDS: frozenset = frozenset({ + "Plains", "Island", "Swamp", "Mountain", "Forest", "Wastes", + "Snow-Covered Plains", "Snow-Covered Island", "Snow-Covered Swamp", + "Snow-Covered Mountain", "Snow-Covered Forest", "Snow-Covered Wastes", +}) + +# Green → amber gradient for 10 histogram bins (cheap → expensive) +_HIST_COLORS = [ + "#34d399", "#3fda8e", "#5de087", "#92e77a", "#c4e66a", + "#f0e05a", "#f5c840", "#f5ab2a", "#f59116", "#f59e0b", +] + + +def compute_price_category_breakdown( + items: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Aggregate per-card prices into build stage buckets for M8 stacked bar chart. + + Each item should have: {card, price, tags: list[str], broad_type (optional)}. + Returns {"totals": {cat: float}, "colors": {cat: hex}, "total": float, "order": [...]}. + """ + totals: Dict[str, float] = {cat: 0.0 for cat in CATEGORY_ORDER} + for item in items: + price = item.get("price") + if price is None: + continue + tags_lower = [str(t).lower() for t in (item.get("tags") or [])] + broad_type = str(item.get("broad_type") or "").lower() + matched = "Other" + # Land check first — use broad_type or tag + if broad_type == "land" or any("land" in t for t in tags_lower): + matched = "Land" + else: + for cat, patterns in _CATEGORY_PATTERNS[1:]: # skip Land; Creature handled below + if any(any(p in t for p in patterns) for t in tags_lower): + matched = cat + break + else: + if broad_type == "creature": + matched = "Creature" + totals[matched] = round(totals[matched] + float(price), 2) + + grand_total = round(sum(totals.values()), 2) + return {"totals": totals, "colors": _CATEGORY_COLORS, "total": grand_total, "order": CATEGORY_ORDER} + + +def compute_price_histogram( + items: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Compute a 10-bin price distribution histogram for M8. + + Uses logarithmic bin boundaries when the price range spans >4x (typical for + MTG decks) so cheap cards are spread across multiple narrow bins rather than + all landing in bin 0. Bar heights use sqrt scaling for a quick-glance view + where even a bin with 1 card is still visibly present. + + Items: list of {card, price, ...}. Cards without price are excluded. + Basic lands are excluded (their near-zero prices skew the distribution). + Returns [] if fewer than 2 priced cards. + Each entry: {label, range_min, range_max, x_label, count, pct, color, cards}. + """ + priced_items = [ + item for item in items + if item.get("price") is not None and item.get("card", "") not in _BASIC_LANDS + ] + prices = [float(item["price"]) for item in priced_items] + if len(prices) < 2: + return [] + + min_p = min(prices) + max_p = max(prices) + + def _card_entry(item: Dict[str, Any]) -> Dict[str, Any]: + return {"name": item["card"], "price": float(item["price"])} + + if max_p == min_p: + # All same price — single populated bin, rest empty + all_cards = sorted([_card_entry(it) for it in priced_items], key=lambda c: c["price"]) + bins: List[Dict[str, Any]] = [] + for i in range(10): + bins.append({ + "label": f"{min_p:.2f}", + "range_min": min_p, + "range_max": max_p, + "x_label": _fmt_price_label(min_p) + "\u2013" + _fmt_price_label(max_p), + "count": len(prices) if i == 0 else 0, + "pct": 100 if i == 0 else 0, + "color": _HIST_COLORS[i], + "cards": all_cards if i == 0 else [], + }) + return bins + + # Choose bin boundary strategy: log-scale when range spans >4x, else linear. + # Clamp lower floor to 0.01 so log doesn't blow up on near-zero prices. + log_floor = max(min_p, 0.01) + use_log = (max_p / log_floor) > 4.0 + + if use_log: + log_lo = math.log(log_floor) + log_hi = math.log(max(max_p, log_floor * 1.001)) + log_step = (log_hi - log_lo) / 10 + boundaries = [math.exp(log_lo + i * log_step) for i in range(11)] + boundaries[0] = min(boundaries[0], min_p) # don't drop cards below float rounding + boundaries[10] = max_p # prevent exp(log(x)) != x float drift from losing last card + else: + step = (max_p - min_p) / 10 + boundaries = [min_p + i * step for i in range(11)] + + max_count = 0 + raw_bins: List[Dict[str, Any]] = [] + for i in range(10): + lo = boundaries[i] + hi = boundaries[i + 1] + if i < 9: + bin_items = [it for it in priced_items if lo <= float(it["price"]) < hi] + else: + bin_items = [it for it in priced_items if lo <= float(it["price"]) <= hi] + count = len(bin_items) + max_count = max(max_count, count) + raw_bins.append({ + "label": f"{lo:.2f}~{hi:.2f}", + "range_min": round(lo, 2), + "range_max": round(hi, 2), + "x_label": _fmt_price_label(lo) + "\u2013" + _fmt_price_label(hi), + "count": count, + "color": _HIST_COLORS[i], + "cards": sorted([_card_entry(it) for it in bin_items], key=lambda c: c["price"]), + }) + + # Sqrt scaling: a bin with 1 card still shows ~13% height vs ~2% with linear. + # This gives a quick-glance shape without the tallest bar crushing small ones. + sqrt_denom = math.sqrt(max_count) if max_count > 0 else 1.0 + for b in raw_bins: + b["pct"] = round(math.sqrt(b["count"]) * 100 / sqrt_denom) + + return raw_bins + + +# --------------------------------------------------------------------------- +# Type aliases +# --------------------------------------------------------------------------- + +# Replacement record: {original, replacement, original_price, replacement_price, price_diff} +Replacement = Dict[str, Any] +# Pickup record: {card, price, tier, priority, tags} +Pickup = Dict[str, Any] +# BudgetReport schema (see class docstring for full spec) +BudgetReport = Dict[str, Any] + + +class BudgetEvaluatorService(BaseService): + """Evaluate a deck list against a budget and suggest replacements. + + Requires access to a ``PriceService`` for price lookups and a card index + for tag-based alternative discovery (loaded lazily from the Parquet file). + + Usage:: + + svc = BudgetEvaluatorService() + report = svc.evaluate_deck( + decklist=["Sol Ring", "Mana Crypt", ...], + budget_total=150.0, + mode="soft", + include_cards=["Mana Crypt"], # exempt from replacement + ) + """ + + def __init__(self, price_service: Optional[PriceService] = None) -> None: + super().__init__() + self._price_svc = price_service or get_price_service() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def evaluate_deck( + self, + decklist: List[str], + budget_total: float, + mode: str = "soft", + *, + card_ceiling: Optional[float] = None, + region: str = "usd", + foil: bool = False, + include_cards: Optional[List[str]] = None, + color_identity: Optional[List[str]] = None, + legacy_fail_open: bool = True, + ) -> BudgetReport: + """Evaluate deck cost versus budget and produce a BudgetReport. + + Args: + decklist: Card names in the deck (one entry per card slot, no + duplicates for normal cards; include the same name once per slot + for multi-copy cards). + budget_total: Maximum total deck cost in USD (or EUR if ``region`` + is ``"eur"``). + mode: ``"soft"`` — advisory only; ``"hard"`` — flags budget + violations but does not auto-replace. + card_ceiling: Optional per-card price cap. Cards priced above this + are flagged independently of the total. + region: Price region — ``"usd"`` (default) or ``"eur"``. + foil: If ``True``, compare foil prices. + include_cards: Cards exempt from budget enforcement (never + auto-flagged for replacement). + color_identity: Commander color identity letters, e.g. ``["U","B"]``. + Used to filter alternatives so they remain legal. + legacy_fail_open: If ``True`` (default), cards with no price data + are skipped in the budget calculation rather than causing an + error. + + Returns: + A ``BudgetReport`` dict with the following keys: + + - ``total_price`` — sum of all prices found + - ``budget_status`` — ``"under"`` | ``"soft_exceeded"`` | + ``"hard_exceeded"`` + - ``overage`` — amount over budget (0 if under) + - ``include_budget_overage`` — cost from include-list cards + - ``over_budget_cards`` — list of {card, price, ceiling_exceeded} + for cards above ceiling or contributing most to overage + - ``price_breakdown`` — per-card {card, price, is_include, + ceiling_exceeded, stale} + - ``stale_prices`` — cards whose price data may be outdated + - ``pickups_list`` — priority-sorted acquisition suggestions + - ``replacements_available`` — alternative cards for over-budget + slots (excludes include-list cards) + """ + self._validate(budget_total >= 0, "budget_total must be non-negative") + self._validate(mode in ("soft", "hard"), "mode must be 'soft' or 'hard'") + if card_ceiling is not None: + self._validate(card_ceiling >= 0, "card_ceiling must be non-negative") + + include_set: Set[str] = {c.lower().strip() for c in (include_cards or [])} + names = [n.strip() for n in decklist if n.strip()] + prices = self._price_svc.get_prices_batch(names, region=region, foil=foil) + + total_price = 0.0 + include_overage = 0.0 + breakdown: List[Dict[str, Any]] = [] + over_budget_cards: List[Dict[str, Any]] = [] + stale: List[str] = [] + + for card in names: + price = prices.get(card) + is_include = card.lower().strip() in include_set + + if price is None: + if not legacy_fail_open: + raise ValueError(f"No price data for '{card}' and legacy_fail_open=False") + stale.append(card) + price_used = 0.0 + else: + price_used = price + + ceil_exceeded = card_ceiling is not None and price_used > card_ceiling + total_price += price_used + + if is_include: + include_overage += price_used + + breakdown.append({ + "card": card, + "price": price_used if price is not None else None, + "is_include": is_include, + "ceiling_exceeded": ceil_exceeded, + }) + + if ceil_exceeded and not is_include: + over_budget_cards.append({ + "card": card, + "price": price_used, + "ceiling_exceeded": True, + }) + + overage = max(0.0, total_price - budget_total) + + if overage == 0.0: + status = "under" + elif mode == "hard": + status = "hard_exceeded" + else: + status = "soft_exceeded" + + # Compute replacements for over-budget / ceiling-exceeded cards + replacements = self._find_replacements( + over_budget_cards=over_budget_cards, + all_prices=prices, + include_set=include_set, + card_ceiling=card_ceiling, + region=region, + foil=foil, + color_identity=color_identity, + ) + + # Build pickups list from cards not in deck, sorted by priority tier + pickups = self._build_pickups_list( + decklist=names, + region=region, + foil=foil, + budget_remaining=max(0.0, budget_total - total_price), + color_identity=color_identity, + ) + + return { + "total_price": round(total_price, 2), + "budget_status": status, + "overage": round(overage, 2), + "include_budget_overage": round(include_overage, 2), + "over_budget_cards": over_budget_cards, + "price_breakdown": breakdown, + "stale_prices": stale, + "pickups_list": pickups, + "replacements_available": replacements, + } + + def find_cheaper_alternatives( + self, + card_name: str, + max_price: float, + *, + region: str = "usd", + foil: bool = False, + color_identity: Optional[List[str]] = None, + tags: Optional[List[str]] = None, + require_type: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Find cards that share tags with *card_name* and cost ≤ *max_price*. + + Args: + card_name: The card to find alternatives for. + max_price: Maximum price for alternatives in the given region. + region: Price region. + foil: If ``True``, compare foil prices. + color_identity: If given, filter to cards legal in this identity. + tags: Tags to use for matching (skips lookup if provided). + require_type: If given, only return cards whose type_line contains + this string (e.g. "Land", "Creature"). Auto-detected from the + card index when not provided. + + Returns: + List of dicts ``{name, price, tags, shared_tags}`` sorted by most + shared tags descending then price ascending, capped at + ``_MAX_ALTERNATIVES``. + """ + lookup_tags = tags or self._get_card_tags(card_name) + if not lookup_tags: + return [] + + # Determine the broad card type for like-for-like filtering. + source_type = require_type or self._get_card_broad_type(card_name) + + candidates: Dict[str, Dict[str, Any]] = {} # name → candidate dict + + try: + from code.web.services.card_index import get_tag_pool, maybe_build_index + maybe_build_index() + + ci_set: Optional[Set[str]] = ( + {c.upper() for c in color_identity} if color_identity else None + ) + + for tag in lookup_tags: + for card in get_tag_pool(tag): + name = card.get("name", "") + if not name or name.lower() == card_name.lower(): + continue + + # Like-for-like type filter (Land→Land, Creature→Creature, etc.) + if source_type: + type_line = card.get("type_line", "") + if source_type not in type_line: + continue + + # Color identity check + if ci_set is not None: + card_colors = set(card.get("color_identity_list", [])) + if card_colors and not card_colors.issubset(ci_set): + continue + + if name not in candidates: + candidates[name] = { + "name": name, + "tags": card.get("tags", []), + "shared_tags": set(), + } + candidates[name]["shared_tags"].add(tag) + + except Exception as exc: + logger.warning("Card index unavailable for alternatives: %s", exc) + return [] + + if not candidates: + return [] + + # Batch price lookup for all candidates + candidate_names = list(candidates.keys()) + prices = self._price_svc.get_prices_batch(candidate_names, region=region, foil=foil) + + results = [] + for name, info in candidates.items(): + price = prices.get(name) + if price is None or price > max_price: + continue + results.append({ + "name": name, + "price": round(price, 2), + "tags": info["tags"], + "shared_tags": sorted(info["shared_tags"]), + }) + + # Sort by most shared tags first (most role-matched), then price ascending. + results.sort(key=lambda x: (-len(x["shared_tags"]), x["price"])) + return results[:_MAX_ALTERNATIVES] + + def calculate_tier_ceilings(self, total_budget: float) -> Dict[str, float]: + """Compute splurge tier price ceilings from *total_budget*. + + S-tier = up to 20 % of budget per card slot + M-tier = up to 10 % + L-tier = up to 5 % + + Args: + total_budget: Total deck budget. + + Returns: + Dict ``{"S": float, "M": float, "L": float}``. + """ + return {tier: round(total_budget * frac, 2) for tier, frac in _TIER_FRACTIONS.items()} + + def generate_pickups_list( + self, + decklist: List[str], + budget_remaining: float, + *, + region: str = "usd", + foil: bool = False, + color_identity: Optional[List[str]] = None, + ) -> List[Pickup]: + """Generate a prioritized acquisition list of cards not in *decklist*. + + Finds cards that fit the color identity, share tags with the current + deck, and cost ≤ *budget_remaining*, sorted by number of shared tags + (most synergistic first). + + Args: + decklist: Current deck card names. + budget_remaining: Maximum price per card to include. + region: Price region. + foil: If ``True``, use foil prices. + color_identity: Commander color identity for legality filter. + + Returns: + List of pickup dicts sorted by synergy score (shared tags count). + """ + return self._build_pickups_list( + decklist=decklist, + region=region, + foil=foil, + budget_remaining=budget_remaining, + color_identity=color_identity, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_card_tags(self, card_name: str) -> List[str]: + """Look up theme tags for a single card from the card index.""" + try: + from code.web.services.card_index import maybe_build_index, _CARD_INDEX + maybe_build_index() + needle = card_name.lower() + for cards in _CARD_INDEX.values(): + for c in cards: + if c.get("name", "").lower() == needle: + return list(c.get("tags", [])) + except Exception: + pass + return [] + + def _get_card_broad_type(self, card_name: str) -> Optional[str]: + """Return the first matching broad MTG type for a card (e.g. 'Land', 'Creature').""" + try: + from code.web.services.card_index import maybe_build_index, _CARD_INDEX + maybe_build_index() + needle = card_name.lower() + for cards in _CARD_INDEX.values(): + for c in cards: + if c.get("name", "").lower() == needle: + type_line = c.get("type_line", "") + for broad in _BROAD_TYPES: + if broad in type_line: + return broad + return None + except Exception: + pass + return None + + def _find_replacements( + self, + *, + over_budget_cards: List[Dict[str, Any]], + all_prices: Dict[str, Optional[float]], + include_set: Set[str], + card_ceiling: Optional[float], + region: str, + foil: bool, + color_identity: Optional[List[str]], + ) -> List[Dict[str, Any]]: + """Find cheaper alternatives for over-budget (non-include) cards.""" + results = [] + for entry in over_budget_cards: + card = entry["card"] + price = entry["price"] + if card.lower().strip() in include_set: + continue + max_alt_price = (card_ceiling - 0.01) if card_ceiling else max(0.0, price - 0.01) + alts = self.find_cheaper_alternatives( + card, + max_price=max_alt_price, + region=region, + foil=foil, + color_identity=color_identity, + ) + if alts: + results.append({ + "original": card, + "original_price": price, + "alternatives": alts, + }) + return results + + def _build_pickups_list( + self, + decklist: List[str], + region: str, + foil: bool, + budget_remaining: float, + color_identity: Optional[List[str]], + ) -> List[Pickup]: + """Build a ranked pickups list using shared-tag scoring.""" + if budget_remaining <= 0: + return [] + + deck_set = {n.lower() for n in decklist} + + # Collect all unique tags from the current deck + deck_tags: Set[str] = set() + try: + from code.web.services.card_index import maybe_build_index, _CARD_INDEX + maybe_build_index() + + for name in decklist: + needle = name.lower() + for cards in _CARD_INDEX.values(): + for c in cards: + if c.get("name", "").lower() == needle: + deck_tags.update(c.get("tags", [])) + break + + if not deck_tags: + return [] + + ci_set: Optional[Set[str]] = ( + {c.upper() for c in color_identity} if color_identity else None + ) + + # Score candidate cards not in deck by shared tags + candidates: Dict[str, Dict[str, Any]] = {} + for tag in deck_tags: + for card in _CARD_INDEX.get(tag, []): + name = card.get("name", "") + if not name or name.lower() in deck_set: + continue + if ci_set: + card_colors = set(card.get("color_identity_list", [])) + if card_colors and not card_colors.issubset(ci_set): + continue + if name not in candidates: + candidates[name] = {"name": name, "tags": card.get("tags", []), "score": 0} + candidates[name]["score"] += 1 + + except Exception as exc: + logger.warning("Could not build pickups list: %s", exc) + return [] + + if not candidates: + return [] + + # Price filter + top_candidates = sorted(candidates.values(), key=lambda x: x["score"], reverse=True)[:200] + names = [c["name"] for c in top_candidates] + prices = self._price_svc.get_prices_batch(names, region=region, foil=foil) + + tier_ceilings = self.calculate_tier_ceilings(budget_remaining) + pickups: List[Pickup] = [] + + for c in top_candidates: + price = prices.get(c["name"]) + if price is None or price > budget_remaining: + continue + tier = "L" + if price <= tier_ceilings["L"]: + tier = "L" + if price <= tier_ceilings["M"]: + tier = "M" + if price <= tier_ceilings["S"]: + tier = "S" + pickups.append({ + "card": c["name"], + "price": round(price, 2), + "tier": tier, + "priority": c["score"], + "tags": c["tags"], + }) + + # Sort: most synergistic first, then cheapest + pickups.sort(key=lambda x: (-x["priority"], x["price"])) + return pickups[:50] diff --git a/code/web/services/build_utils.py b/code/web/services/build_utils.py index 8c11c56..e8dc326 100644 --- a/code/web/services/build_utils.py +++ b/code/web/services/build_utils.py @@ -108,6 +108,26 @@ def step5_base_ctx(request: Request, sess: dict, *, include_name: bool = True, i "allow_illegal": bool(sess.get("allow_illegal")), "fuzzy_matching": bool(sess.get("fuzzy_matching", True)), } + ctx["budget_config"] = sess.get("budget_config") or {} + ctx["build_id"] = str(sess.get("build_id") or "0") + try: + from ..services.price_service import get_price_service + from code.settings import PRICE_STALE_WARNING_HOURS + _svc = get_price_service() + _svc._ensure_loaded() + ctx["price_cache"] = _svc._cache # keyed by lowercase card name → {usd, usd_foil, ...} + _stale = _svc.get_stale_cards(PRICE_STALE_WARNING_HOURS) if PRICE_STALE_WARNING_HOURS > 0 else set() + # Suppress per-card noise when >50% of the priced pool is stale + if _stale and len(_stale) > len(_svc._cache) * 0.5: + ctx["stale_prices"] = set() + ctx["stale_prices_global"] = True + else: + ctx["stale_prices"] = _stale + ctx["stale_prices_global"] = False + except Exception: + ctx["price_cache"] = {} + ctx["stale_prices"] = set() + ctx["stale_prices_global"] = False return ctx @@ -168,6 +188,7 @@ def start_ctx_from_session(sess: dict, *, set_on_session: bool = True) -> Dict[s partner_feature_enabled=partner_enabled, secondary_commander=secondary_commander, background_commander=background_choice, + budget_config=sess.get("budget_config"), ) if set_on_session: sess["build_ctx"] = ctx @@ -523,9 +544,166 @@ def step5_ctx_from_result( ctx["summary_token"] = token_val ctx["summary_ready"] = bool(sess.get("step5_summary_ready")) ctx["synergies"] = synergies_list + + # M5: Post-build budget review (only when build is done and budget mode active) + if done: + try: + _apply_budget_review_ctx(sess, res, ctx) + except Exception: + ctx.setdefault("over_budget_review", False) + ctx.setdefault("budget_review_visible", False) + ctx.setdefault("price_category_chart", None) + ctx.setdefault("price_histogram_chart", None) + else: + ctx.setdefault("over_budget_review", False) + ctx.setdefault("budget_review_visible", False) + ctx.setdefault("price_category_chart", None) + ctx.setdefault("price_histogram_chart", None) + return ctx +def _apply_budget_review_ctx(sess: dict, res: dict, ctx: dict) -> None: + """M5: Compute end-of-build budget review data and inject into ctx. + + Triggers when total deck cost exceeds budget_total by more than BUDGET_TOTAL_TOLERANCE. + Shows the most expensive non-include cards (contributors to total overage) with + cheaper alternatives drawn from find_cheaper_alternatives(). + """ + budget_cfg = sess.get("budget_config") or {} + try: + budget_total = float(budget_cfg.get("total") or 0) + except Exception: + budget_total = 0.0 + if budget_total <= 0: + ctx["over_budget_review"] = False + return + + budget_mode = "soft" + try: + card_ceiling = float(budget_cfg.get("card_ceiling")) if budget_cfg.get("card_ceiling") else None + except Exception: + card_ceiling = None + include_cards = [str(c).strip() for c in (sess.get("include_cards") or []) if str(c).strip()] + + # Extract card names from build summary + build name -> {type, role, tags} lookup + summary = res.get("summary") or {} + card_names: list[str] = [] + card_meta: dict[str, dict] = {} + if isinstance(summary, dict): + tb = summary.get("type_breakdown") or {} + for type_key, type_cards_list in (tb.get("cards") or {}).items(): + for c in type_cards_list: + name = c.get("name") if isinstance(c, dict) else None + if name: + sname = str(name).strip() + card_names.append(sname) + card_meta[sname] = { + "type": type_key, + "role": str(c.get("role") or "").strip(), + "tags": list(c.get("tags") or []), + } + + if not card_names: + ctx["over_budget_review"] = False + return + + # Persist snapshot for the swap route + sess["budget_deck_snapshot"] = list(card_names) + + color_identity: list[str] | None = None + try: + ci_raw = sess.get("color_identity") + if ci_raw and isinstance(ci_raw, list): + color_identity = [str(c).upper() for c in ci_raw] + except Exception: + pass + + from ..services.budget_evaluator import BudgetEvaluatorService + svc = BudgetEvaluatorService() + report = svc.evaluate_deck( + card_names, + budget_total, + mode=budget_mode, + card_ceiling=card_ceiling, + include_cards=include_cards, + color_identity=color_identity, + ) + + total_price = float(report.get("total_price", 0.0)) + tolerance = bc.BUDGET_TOTAL_TOLERANCE + over_budget_review = total_price > budget_total * (1.0 + tolerance) + + ctx["budget_review_visible"] = over_budget_review # only shown when deck total exceeds tolerance + ctx["over_budget_review"] = over_budget_review + ctx["budget_review_total"] = round(total_price, 2) + ctx["budget_review_cap"] = round(budget_total, 2) + ctx["budget_overage_pct"] = 0.0 + ctx["over_budget_cards"] = [] + + overage = total_price - budget_total + if over_budget_review: + ctx["budget_overage_pct"] = round(overage / budget_total * 100, 1) + + include_set = {c.lower().strip() for c in include_cards} + + # Use price_breakdown sorted by price desc — most expensive cards are the biggest + # contributors to the total overage regardless of any per-card ceiling. + breakdown = report.get("price_breakdown") or [] + priced = sorted( + [e for e in breakdown + if not e.get("is_include") and (e.get("price") is not None) and float(e.get("price") or 0.0) > 0], + key=lambda x: -float(x.get("price") or 0.0), + ) + + over_cards_out: list[dict] = [] + for entry in priced[:6]: + name = entry.get("card", "") + price = float(entry.get("price") or 0.0) + is_include = name.lower().strip() in include_set + meta = card_meta.get(name, {}) + try: + # Any cheaper alternative reduces the total; use price - 0.01 as the ceiling + alts_raw = svc.find_cheaper_alternatives( + name, + max_price=max(0.0, price - 0.01), + region="usd", + color_identity=color_identity, + tags=meta.get("tags") or None, + require_type=meta.get("type") or None, + ) + except Exception: + alts_raw = [] + over_cards_out.append({ + "name": name, + "price": price, + "swap_disabled": is_include, + "card_type": meta.get("type", ""), + "card_role": meta.get("role", ""), + "card_tags": meta.get("tags", []), + "alternatives": [ + {"name": a["name"], "price": a.get("price"), "shared_tags": a.get("shared_tags", [])} + for a in alts_raw[:3] + ], + }) + + ctx["over_budget_cards"] = over_cards_out + + # M8: Price charts — category breakdown + histogram + try: + from ..services.budget_evaluator import compute_price_category_breakdown, compute_price_histogram + _breakdown = report.get("price_breakdown") or [] + _enriched = [ + {**item, "tags": card_meta.get(item.get("card", ""), {}).get("tags", [])} + for item in _breakdown + ] + ctx["price_category_chart"] = compute_price_category_breakdown(_enriched) + ctx["price_histogram_chart"] = compute_price_histogram(_breakdown) + except Exception: + ctx.setdefault("price_category_chart", None) + ctx.setdefault("price_histogram_chart", None) + + def step5_error_ctx( request: Request, sess: dict, diff --git a/code/web/services/card_index.py b/code/web/services/card_index.py index eac6e7b..279f8e3 100644 --- a/code/web/services/card_index.py +++ b/code/web/services/card_index.py @@ -82,6 +82,7 @@ def maybe_build_index() -> None: color_id = str(row.get(COLOR_IDENTITY_COL) or "").strip() mana_cost = str(row.get(MANA_COST_COL) or "").strip() rarity = _normalize_rarity(str(row.get(RARITY_COL) or "")) + type_line = str(row.get("type") or row.get("type_line") or "").strip() for tg in tags: if not tg: @@ -92,6 +93,7 @@ def maybe_build_index() -> None: "tags": tags, "mana_cost": mana_cost, "rarity": rarity, + "type_line": type_line, "color_identity_list": [c.strip() for c in color_id.split(',') if c.strip()], "pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}], }) diff --git a/code/web/services/orchestrator.py b/code/web/services/orchestrator.py index 9e64821..c486277 100644 --- a/code/web/services/orchestrator.py +++ b/code/web/services/orchestrator.py @@ -2037,6 +2037,9 @@ def run_build(commander: str, tags: List[str], bracket: int, ideals: Dict[str, i custom_base = None if isinstance(custom_base, str) and custom_base.strip(): meta["name"] = custom_base.strip() + budget_cfg = getattr(b, 'budget_config', None) + if isinstance(budget_cfg, dict) and budget_cfg.get('total'): + meta['budget_config'] = budget_cfg payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) @@ -2516,6 +2519,7 @@ def start_build_ctx( partner_feature_enabled: bool | None = None, secondary_commander: str | None = None, background_commander: str | None = None, + budget_config: Dict[str, Any] | None = None, ) -> Dict[str, Any]: logs: List[str] = [] @@ -2667,6 +2671,15 @@ def start_build_ctx( except Exception: pass # Stages + try: + b.budget_config = dict(budget_config) if isinstance(budget_config, dict) else None + except Exception: + b.budget_config = None + # M4: Apply budget pool filter now that budget_config is set + try: + b.apply_budget_pool_filter() + except Exception: + pass stages = _make_stages(b) ctx = { "builder": b, @@ -2867,6 +2880,9 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal custom_base = None if isinstance(custom_base, str) and custom_base.strip(): meta["name"] = custom_base.strip() + budget_cfg = getattr(b, 'budget_config', None) + if isinstance(budget_cfg, dict) and budget_cfg.get('total'): + meta['budget_config'] = budget_cfg payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) @@ -3718,6 +3734,9 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal custom_base = None if isinstance(custom_base, str) and custom_base.strip(): meta["name"] = custom_base.strip() + budget_cfg = getattr(b, 'budget_config', None) + if isinstance(budget_cfg, dict) and budget_cfg.get('total'): + meta['budget_config'] = budget_cfg payload = {"meta": meta, "summary": summary} with open(sidecar, 'w', encoding='utf-8') as f: _json.dump(payload, f, ensure_ascii=False, indent=2) diff --git a/code/web/services/price_service.py b/code/web/services/price_service.py new file mode 100644 index 0000000..e2f6409 --- /dev/null +++ b/code/web/services/price_service.py @@ -0,0 +1,504 @@ +"""Price service for card price lookups. + +Loads prices from the local Scryfall bulk data file (one card per line), +caches results in a compact JSON file under card_files/, and provides +thread-safe batch lookups for budget evaluation. + +Cache strategy: + - On first access, load from prices_cache.json if < TTL hours old + - If cache is stale or missing, rebuild by streaming the bulk data file + - In-memory dict (normalized lowercase key) is kept for fast lookups + - Background refresh available via refresh_cache_background() +""" +from __future__ import annotations + +import json +import os +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +from code.path_util import card_files_dir, card_files_raw_dir +from code.web.services.base import BaseService +from code import logging_util + +logger = logging_util.logging.getLogger(__name__) +logger.setLevel(logging_util.LOG_LEVEL) +logger.addHandler(logging_util.file_handler) +logger.addHandler(logging_util.stream_handler) + +_CACHE_TTL_SECONDS = 86400 # 24 hours +_BULK_DATA_FILENAME = "scryfall_bulk_data.json" +_PRICE_CACHE_FILENAME = "prices_cache.json" + + +class PriceService(BaseService): + """Service for card price lookups backed by Scryfall bulk data. + + Reads prices from the local Scryfall bulk data file that the setup + pipeline already downloads. A compact JSON cache is written to + card_files/ so subsequent startups load instantly without re-scanning + the 500 MB bulk file. + + All public methods are thread-safe. + """ + + def __init__( + self, + *, + bulk_data_path: Optional[str] = None, + cache_path: Optional[str] = None, + cache_ttl: int = _CACHE_TTL_SECONDS, + ) -> None: + super().__init__() + self._bulk_path: str = bulk_data_path or os.path.join( + card_files_raw_dir(), _BULK_DATA_FILENAME + ) + self._cache_path: str = cache_path or os.path.join( + card_files_dir(), _PRICE_CACHE_FILENAME + ) + self._ttl: int = cache_ttl + + # {normalized_card_name: {"usd": float, "usd_foil": float, "eur": float, "eur_foil": float}} + self._cache: Dict[str, Dict[str, float]] = {} + self._lock = threading.RLock() + self._loaded = False + self._last_refresh: float = 0.0 + self._hit_count = 0 + self._miss_count = 0 + self._refresh_thread: Optional[threading.Thread] = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def get_price( + self, + card_name: str, + region: str = "usd", + foil: bool = False, + ) -> Optional[float]: + """Return the price for *card_name* or ``None`` if not found. + + Args: + card_name: Card name (case-insensitive). + region: Price region - ``"usd"`` or ``"eur"``. + foil: If ``True`` return foil price. + + Returns: + Price as float or ``None`` when missing / card unknown. + """ + self._ensure_loaded() + price_key = region + ("_foil" if foil else "") + entry = self._cache.get(card_name.lower().strip()) + self.queue_lazy_refresh(card_name) + with self._lock: + if entry is not None: + self._hit_count += 1 + return entry.get(price_key) + self._miss_count += 1 + return None + + def get_prices_batch( + self, + card_names: List[str], + region: str = "usd", + foil: bool = False, + ) -> Dict[str, Optional[float]]: + """Return a mapping of card name → price for all requested cards. + + Missing cards map to ``None``. Preserves input ordering and + original case in the returned keys. + + Args: + card_names: List of card names to look up. + region: Price region - ``"usd"`` or ``"eur"``. + foil: If ``True`` return foil prices. + + Returns: + Dict mapping each input name to its price (or ``None``). + """ + self._ensure_loaded() + price_key = region + ("_foil" if foil else "") + result: Dict[str, Optional[float]] = {} + hits = 0 + misses = 0 + for name in card_names: + entry = self._cache.get(name.lower().strip()) + if entry is not None: + result[name] = entry.get(price_key) + hits += 1 + else: + result[name] = None + misses += 1 + with self._lock: + self._hit_count += hits + self._miss_count += misses + return result + + def cache_stats(self) -> Dict[str, Any]: + """Return telemetry snapshot about cache performance. + + Returns: + Dict with ``total_entries``, ``hit_count``, ``miss_count``, + ``hit_rate``, ``last_refresh``, ``loaded``, ``cache_path``. + """ + self._ensure_loaded() + with self._lock: + total = self._hit_count + self._miss_count + return { + "total_entries": len(self._cache), + "hit_count": self._hit_count, + "miss_count": self._miss_count, + "hit_rate": (self._hit_count / total) if total > 0 else 0.0, + "last_refresh": self._last_refresh, + "loaded": self._loaded, + "cache_path": self._cache_path, + "bulk_data_available": os.path.exists(self._bulk_path), + } + + def refresh_cache_background(self) -> None: + """Spawn a daemon thread to rebuild the price cache asynchronously. + + If a refresh is already in progress, this call is a no-op. + """ + with self._lock: + if self._refresh_thread and self._refresh_thread.is_alive(): + logger.debug("Price cache background refresh already running") + return + t = threading.Thread( + target=self._rebuild_cache, + daemon=True, + name="price-cache-refresh", + ) + self._refresh_thread = t + t.start() + + def get_cache_built_at(self) -> str | None: + """Return a human-readable price cache build date, or None if unavailable.""" + try: + if os.path.exists(self._cache_path): + import datetime + built = os.path.getmtime(self._cache_path) + if built: + dt = datetime.datetime.fromtimestamp(built, tz=datetime.timezone.utc) + return dt.strftime("%B %d, %Y") + except Exception: + pass + return None + + def start_daily_refresh(self, hour: int = 1, on_after_rebuild: Optional[Callable] = None) -> None: + """Start a daemon thread that rebuilds prices once daily at *hour* UTC. + + Checks every 30 minutes. Safe to call multiple times — only one + scheduler thread will be started. + + Args: + hour: UTC hour (0–23) at which to run the nightly rebuild. + on_after_rebuild: Optional callable invoked after each successful + rebuild (e.g., to update the parquet files). + """ + with self._lock: + if getattr(self, "_daily_thread", None) and self._daily_thread.is_alive(): # type: ignore[attr-defined] + return + + def _loop() -> None: + import datetime + last_date: "datetime.date | None" = None + while True: + try: + now = datetime.datetime.now(tz=datetime.timezone.utc) + today = now.date() + if now.hour >= hour and today != last_date: + logger.info("Scheduled price refresh running (daily at %02d:00 UTC) …", hour) + self._rebuild_cache() + last_date = today + if on_after_rebuild: + try: + on_after_rebuild() + except Exception as exc: + logger.error("on_after_rebuild callback failed: %s", exc) + logger.info("Scheduled price refresh complete.") + except Exception as exc: + logger.error("Daily price refresh error: %s", exc) + time.sleep(1800) + + t = threading.Thread(target=_loop, daemon=True, name="price-daily-refresh") + self._daily_thread = t # type: ignore[attr-defined] + t.start() + logger.info("Daily price refresh scheduler started (hour=%d UTC)", hour) + + def start_lazy_refresh(self, stale_days: int = 7) -> None: + """Start a background worker that refreshes per-card prices from the + Scryfall API when they have not been updated within *stale_days* days. + + Queuing: call queue_lazy_refresh(card_name) to mark a card as stale. + The worker runs every 60 seconds, processes up to 20 cards per cycle, + and respects Scryfall's 100 ms rate-limit guideline. + """ + with self._lock: + if getattr(self, "_lazy_thread", None) and self._lazy_thread.is_alive(): # type: ignore[attr-defined] + return + self._lazy_stale_seconds: float = stale_days * 86400 + self._lazy_queue: set[str] = set() + self._lazy_ts: dict[str, float] = self._load_lazy_ts() + self._lazy_lock = threading.Lock() + + def _worker() -> None: + while True: + try: + time.sleep(60) + with self._lazy_lock: + batch = list(self._lazy_queue)[:20] + self._lazy_queue -= set(batch) + if batch: + self._fetch_lazy_batch(batch) + except Exception as exc: + logger.error("Lazy price refresh error: %s", exc) + + t = threading.Thread(target=_worker, daemon=True, name="price-lazy-refresh") + self._lazy_thread = t # type: ignore[attr-defined] + t.start() + logger.info("Lazy price refresh worker started (stale_days=%d)", stale_days) + + def queue_lazy_refresh(self, card_name: str) -> None: + """Mark *card_name* for a lazy per-card price update if its cached + price is stale or missing. No-op when lazy mode is not enabled.""" + if not hasattr(self, "_lazy_queue"): + return + key = card_name.lower().strip() + ts = self._lazy_ts.get(key) + if ts is None or (time.time() - ts) > self._lazy_stale_seconds: + with self._lazy_lock: + self._lazy_queue.add(card_name.strip()) + + def _fetch_lazy_batch(self, names: list[str]) -> None: + """Fetch fresh prices for *names* from the Scryfall named-card API.""" + import urllib.request as _urllib + import urllib.parse as _urlparse + now = time.time() + updated: dict[str, float] = {} + for name in names: + try: + url = "https://api.scryfall.com/cards/named?" + _urlparse.urlencode({"exact": name, "format": "json"}) + req = _urllib.Request(url, headers={"User-Agent": "MTGPythonDeckbuilder/1.0"}) + with _urllib.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read().decode()) + raw_prices: dict = data.get("prices") or {} + entry = self._extract_prices(raw_prices) + if entry: + key = name.lower() + with self._lock: + self._cache[key] = entry + updated[key] = now + logger.debug("Lazy refresh: %s → $%.2f", name, entry.get("usd", 0)) + except Exception as exc: + logger.debug("Lazy price fetch skipped for %s: %s", name, exc) + time.sleep(0.1) # 100 ms — Scryfall rate-limit guideline + if updated: + self._lazy_ts.update(updated) + self._save_lazy_ts() + # Also persist the updated in-memory cache to the JSON cache file + try: + self._persist_cache_snapshot() + except Exception: + pass + + def _load_lazy_ts(self) -> dict[str, float]: + """Load per-card timestamps from companion file.""" + ts_path = self._cache_path + ".ts" + try: + if os.path.exists(ts_path): + with open(ts_path, "r", encoding="utf-8") as fh: + return json.load(fh) + except Exception: + pass + return {} + + def _save_lazy_ts(self) -> None: + """Atomically persist per-card timestamps.""" + ts_path = self._cache_path + ".ts" + tmp = ts_path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(self._lazy_ts, fh, separators=(",", ":")) + os.replace(tmp, ts_path) + except Exception as exc: + logger.warning("Failed to save lazy timestamps: %s", exc) + + def get_stale_cards(self, threshold_hours: int = 24) -> set[str]: + """Return the set of card names whose cached price is older than *threshold_hours*. + + Uses the per-card timestamp sidecar (``prices_cache.json.ts``). If the + sidecar is absent, all priced cards are considered stale (safe default). + Returns an empty set when *threshold_hours* is 0 (warnings disabled). + Card names are returned in their original (display-name) casing as stored + in ``self._cache``. + """ + import time as _t + if threshold_hours <= 0: + return set() + cutoff = _t.time() - threshold_hours * 3600 + with self._lock: + ts_map: dict[str, float] = dict(self._lazy_ts) + cached_keys: set[str] = set(self._cache.keys()) + stale: set[str] = set() + for key in cached_keys: + ts = ts_map.get(key) + if ts is None or ts < cutoff: + stale.add(key) + return stale + + def _persist_cache_snapshot(self) -> None: + """Write the current in-memory cache to the JSON cache file (atomic).""" + import time as _t + with self._lock: + snapshot = dict(self._cache) + built = self._last_refresh or _t.time() + cache_data = {"prices": snapshot, "built_at": built} + tmp_path = self._cache_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(cache_data, fh, separators=(",", ":")) + os.replace(tmp_path, self._cache_path) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _ensure_loaded(self) -> None: + """Lazy-load the price cache on first access (double-checked lock).""" + if self._loaded: + return + with self._lock: + if self._loaded: + return + self._load_or_rebuild() + self._loaded = True + + def _load_or_rebuild(self) -> None: + """Load from JSON cache if fresh; otherwise rebuild from bulk data.""" + if os.path.exists(self._cache_path): + try: + age = time.time() - os.path.getmtime(self._cache_path) + if age < self._ttl: + self._load_from_cache_file() + logger.info( + "Loaded %d prices from cache (age %.1fh)", + len(self._cache), + age / 3600, + ) + return + logger.info("Price cache stale (%.1fh old), rebuilding", age / 3600) + except Exception as exc: + logger.warning("Price cache unreadable, rebuilding: %s", exc) + self._rebuild_cache() + + def _load_from_cache_file(self) -> None: + """Deserialize the compact prices cache JSON into memory.""" + with open(self._cache_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + self._cache = data.get("prices", {}) + self._last_refresh = data.get("built_at", 0.0) + + def _rebuild_cache(self) -> None: + """Stream the Scryfall bulk data file and extract prices. + + Writes a compact cache JSON then swaps the in-memory dict. + Uses an atomic rename so concurrent readers see a complete file. + """ + if not os.path.exists(self._bulk_path): + logger.warning("Scryfall bulk data not found at %s", self._bulk_path) + return + + logger.info("Building price cache from %s ...", self._bulk_path) + new_cache: Dict[str, Dict[str, float]] = {} + built_at = time.time() + + try: + with open(self._bulk_path, "r", encoding="utf-8") as fh: + for raw_line in fh: + line = raw_line.strip().rstrip(",") + if not line or line in ("[", "]"): + continue + try: + card = json.loads(line) + except json.JSONDecodeError: + continue + + name: str = card.get("name", "") + prices: Dict[str, Any] = card.get("prices") or {} + if not name: + continue + + entry = self._extract_prices(prices) + if not entry: + continue + + # Index by both the combined name and each face name + names_to_index = [name] + if " // " in name: + names_to_index += [part.strip() for part in name.split(" // ")] + + for idx_name in names_to_index: + key = idx_name.lower() + existing = new_cache.get(key) + # Prefer cheapest non-foil USD price across printings + new_usd = entry.get("usd", 9999.0) + if existing is None or new_usd < existing.get("usd", 9999.0): + new_cache[key] = entry + + except Exception as exc: + logger.error("Failed to parse bulk data: %s", exc) + return + + # Write compact cache atomically + try: + cache_data = {"prices": new_cache, "built_at": built_at} + tmp_path = self._cache_path + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as fh: + json.dump(cache_data, fh, separators=(",", ":")) + os.replace(tmp_path, self._cache_path) + logger.info( + "Price cache written: %d cards → %s", len(new_cache), self._cache_path + ) + except Exception as exc: + logger.error("Failed to write price cache: %s", exc) + + with self._lock: + self._cache = new_cache + self._last_refresh = built_at + # Stamp all keys as fresh so get_stale_cards() reflects the rebuild + for key in new_cache: + self._lazy_ts[key] = built_at + self._save_lazy_ts() + + @staticmethod + def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]: + """Convert raw Scryfall prices dict to {region_key: float} entries.""" + result: Dict[str, float] = {} + for key in ("usd", "usd_foil", "eur", "eur_foil"): + raw = prices.get(key) + if raw is not None and raw != "": + try: + result[key] = float(raw) + except (ValueError, TypeError): + pass + return result + + +# --------------------------------------------------------------------------- +# Module-level singleton (lazy) +# --------------------------------------------------------------------------- + +_INSTANCE: Optional[PriceService] = None +_INSTANCE_LOCK = threading.Lock() + + +def get_price_service() -> PriceService: + """Return the shared PriceService singleton, creating it on first call.""" + global _INSTANCE + if _INSTANCE is None: + with _INSTANCE_LOCK: + if _INSTANCE is None: + _INSTANCE = PriceService() + return _INSTANCE diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 4aa1b9e..f0ca7d4 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -5859,3 +5859,267 @@ footer.site-footer { } } +/* ============================================================ + Budget Mode — Badge, Tier Labels, Price Tooltip + ============================================================ */ + +.budget-badge { + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .3rem .75rem; + border-radius: 999px; + font-size: .85rem; + font-weight: 600; + border: 1.5px solid currentColor; +} + +.budget-badge--under { + color: var(--ok, #16a34a); + background: color-mix(in srgb, var(--ok, #16a34a) 12%, var(--panel, #1a1b1e) 88%); +} + +.budget-badge--soft_exceeded { + color: var(--warn, #f59e0b); + background: color-mix(in srgb, var(--warn, #f59e0b) 12%, var(--panel, #1a1b1e) 88%); +} + +.budget-badge--hard_exceeded { + color: var(--err, #ef4444); + background: color-mix(in srgb, var(--err, #ef4444) 12%, var(--panel, #1a1b1e) 88%); +} + +/* Tier badges on the pickups table */ +.tier-badge { + display: inline-block; + padding: .1rem .5rem; + border-radius: 4px; + font-size: .78rem; + font-weight: 700; + letter-spacing: .04em; + background: var(--panel, #1a1b1e); + border: 1px solid var(--border, #333); +} + +.tier-badge--s { + color: var(--ok, #16a34a); + border-color: var(--ok, #16a34a); +} + +.tier-badge--m { + color: var(--warn, #f59e0b); + border-color: var(--warn, #f59e0b); +} + +.tier-badge--l { + color: var(--muted, #b6b8bd); +} + +/* Inline price tooltip on card names */ +.card-name-price-hover { + cursor: default; + position: relative; +} + +.card-price-tip { + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: var(--surface, #0f1115); + border: 1px solid var(--border, #333); + border-radius: 6px; + padding: .25rem .6rem; + font-size: .78rem; + white-space: nowrap; + z-index: 9000; + pointer-events: none; + color: var(--text, #e5e7eb); + box-shadow: 0 4px 12px rgba(0,0,0,.4); +} + +/* Price overlay on card thumbnails (step5 tiles + deck summary thumbs) */ +.card-price-overlay { + position: absolute; + top: 6px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, .72); + color: #fff; + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 10px; + pointer-events: none; + z-index: 3; + white-space: nowrap; + line-height: 16px; +} +.card-price-overlay:empty { display: none; } + +/* Inline price in deck summary list rows */ +.card-price-inline { + font-size: 11px; + color: var(--muted, #94a3b8); + font-variant-numeric: tabular-nums; + white-space: nowrap; + padding: 0 2px; +} +.card-price-inline:empty { color: transparent; } + +/* Over-budget highlight — gold/amber, matching the locked card style */ +.card-tile.over-budget { + border-color: #f5c518 !important; + box-shadow: inset 0 0 8px rgba(245, 197, 24, .25), 0 0 5px #f5c518 !important; +} +.stack-card.over-budget { + border-color: #f5c518 !important; + box-shadow: 0 6px 18px rgba(0,0,0,.55), 0 0 7px #f5c518 !important; +} +.list-row.over-budget .name { + background: rgba(245, 197, 24, .12); + box-shadow: 0 0 0 1px #f5c518; + border-radius: 4px; +} + +/* Budget price summary bar in deck summary */ +.budget-price-bar { + font-size: 13px; + padding: .3rem .5rem; + border-radius: 6px; + margin: .4rem 0 .6rem 0; + border: 1px solid var(--border, #333); + background: var(--panel, #1a1f2e); +} +.budget-price-bar.under { border-color: #34d399; color: #a7f3d0; } +.budget-price-bar.over { border-color: #f5c518; color: #fde68a; } + +/* M5: Budget review panel */ +.budget-review-panel { + border: 1px solid var(--border, #444); + border-left: 4px solid #f5c518; + border-radius: 6px; + background: var(--panel, #1a1f2e); + padding: .75rem 1rem; +} +.budget-review-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + margin-bottom: .5rem; +} +.budget-review-summary { flex: 1 1 auto; } +.budget-review-cards { display: flex; flex-direction: column; gap: .5rem; margin-top: .5rem; } +.budget-review-card-row { + border: 1px solid var(--border, #333); + border-radius: 4px; + padding: .4rem .6rem; + background: var(--bg, #141824); +} +.budget-review-card-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .4rem; + margin-bottom: .25rem; +} +.budget-review-card-name { font-weight: 600; } +.budget-review-card-price { color: #f5c518; } +.budget-review-alts { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; } +.btn-alt-swap { + font-size: .8rem; + padding: .2rem .5rem; + border: 1px solid var(--border, #555); + border-radius: 4px; + background: var(--panel, #1a1f2e); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: .3rem; +} +.btn-alt-swap:hover { background: var(--hover, #252d3d); } +.alt-price { color: #34d399; font-size: .75rem; } +.budget-review-no-alts { font-size: .8rem; } +.budget-review-subtitle { font-size: .85rem; margin-bottom: .5rem; } +.budget-review-actions { display: flex; flex-wrap: wrap; gap: .5rem; } +.chip-red { background: rgba(239,68,68,.15); color: #fca5a5; border-color: #ef4444; } +.chip-green { background: rgba(34,197,94,.15); color: #86efac; border-color: #22c55e; } +.chip-subtle { background: rgba(148,163,184,.08); color: var(--muted, #94a3b8); border-color: rgba(148,163,184,.2); font-size: .7rem; padding: 1px 6px; } + +/* M8: Price category stacked bar */ +.price-cat-section { margin: .6rem 0 .2rem 0; } +.price-cat-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; } +.price-cat-bar { + display: flex; + height: 18px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border, #333); + background: var(--panel, #1a1f2e); +} +.price-cat-seg { + height: 100%; + transition: opacity .15s; + position: relative; +} +.price-cat-seg:hover { opacity: .75; cursor: default; } +.price-cat-legend { + display: flex; + flex-wrap: wrap; + gap: .15rem .6rem; + margin-top: .3rem; + font-size: 11px; + color: var(--muted, #94a3b8); +} +.price-cat-legend-item { display: flex; align-items: center; gap: .3rem; } +.price-cat-swatch { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; } + +/* M8: Price histogram bars */ +.price-hist-section { margin: .75rem 0 .2rem 0; } +.price-hist-heading { font-size: 12px; color: var(--muted, #94a3b8); margin-bottom: .3rem; font-weight: 600; } +.price-hist-bars { + display: flex; + align-items: flex-end; + gap: 3px; + height: 80px; + margin-bottom: 0; +} +.price-hist-column { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; + cursor: pointer; + transition: opacity .15s; +} +.price-hist-column:hover { opacity: .8; } +.price-hist-bar { + width: 100%; + border-radius: 3px 3px 0 0; + min-height: 2px; +} +.price-hist-xlabels { + display: flex; + gap: 3px; + margin-top: 2px; + margin-bottom: .25rem; +} +.price-hist-xlabel { + flex: 1; + font-size: 10px; + color: var(--muted, #94a3b8); + text-align: center; + overflow-wrap: anywhere; + word-break: break-all; + line-height: 1.2; +} +.price-hist-count { font-size: 11px; color: var(--muted, #94a3b8); margin-top: .1rem; } + +/* M9: Stale price indicators */ +.stale-price-indicator { position: absolute; top: 4px; right: 4px; font-size: 10px; color: #f59e0b; cursor: default; pointer-events: auto; z-index: 2; } +.stale-price-badge { font-size: 10px; color: #f59e0b; margin-left: 2px; vertical-align: middle; cursor: default; } +.stale-banner { background: rgba(245,158,11,.08); border: 1px solid rgba(245,158,11,.35); border-radius: 6px; padding: .4rem .75rem; font-size: 12px; color: #f59e0b; margin-bottom: .6rem; } + diff --git a/code/web/static/ts/cardHover.ts b/code/web/static/ts/cardHover.ts index 15f0836..067ff71 100644 --- a/code/web/static/ts/cardHover.ts +++ b/code/web/static/ts/cardHover.ts @@ -122,6 +122,7 @@ interface PointerEventLike { '
' + '
 
' + '
' + + '
' + '' + '
' + '
' + @@ -158,6 +159,7 @@ interface PointerEventLike { const imgEl = panel.querySelector('.hcp-img') as HTMLImageElement; const nameEl = panel.querySelector('.hcp-name') as HTMLElement; const rarityEl = panel.querySelector('.hcp-rarity') as HTMLElement; + const priceEl = panel.querySelector('.hcp-price') as HTMLElement; const metaEl = panel.querySelector('.hcp-meta') as HTMLElement; const reasonsList = panel.querySelector('.hcp-reasons') as HTMLElement; const tagsEl = panel.querySelector('.hcp-tags') as HTMLElement; @@ -393,6 +395,14 @@ interface PointerEventLike { nameEl.textContent = nm; rarityEl.textContent = rarity; + if (priceEl) { + const priceRaw = (attr('data-price') || '').trim(); + const priceNum = priceRaw ? parseFloat(priceRaw) : NaN; + const isStale = attr('data-stale') === '1'; + priceEl.innerHTML = !isNaN(priceNum) + ? '$' + priceNum.toFixed(2) + (isStale ? ' \u23F1' : '') + : ''; + } const roleLabel = displayLabel(role); const roleKey = (roleLabel || role || '').toLowerCase(); diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 2949ce3..c1d9134 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -118,6 +118,7 @@ Card images and data provided by Scryfall. This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast. + {% set _pba = _price_cache_ts() %}{% if _pba %}
Prices as of {{ _pba }} — for live pricing visit Scryfall.{% endif %}
+ {% if budget_config and budget_config.total %} +
Loading deck cost...
+ {% endif %} {% for t in tb.order %}
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %} @@ -46,7 +52,7 @@ @media (max-width: 1199px) { .list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } } - .list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) auto 1.6em; align-items:center; column-gap:.45rem; width:100%; } + .list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) auto auto 1.6em; align-items:center; column-gap:.45rem; width:100%; } .list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; } .list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } .list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -75,6 +81,7 @@ {{ cnt }} x {{ c.name }} +
{% endif %} + +
+ Card Price Cache Status +
+
Last updated:
+
+ {% if price_cache_built_at %}{{ price_cache_built_at }}{% else %}Not yet generated — run Setup first, then refresh prices.{% endif %} +
+ {% if price_auto_refresh %} +
Auto-refresh is enabled (runs daily at 01:00 UTC).
+ {% endif %} +
+
+ {% if not price_auto_refresh %} +
+ {{ button('Refresh Card Prices', variant='primary', onclick='refreshPriceCache()', attrs='id="btn-refresh-prices"') }} + Rebuilds price data from local Scryfall bulk data (requires Setup to have run). +
+ {% endif %}