feat: add Budget Mode with price cache infrastructure and stale price warnings

This commit is contained in:
matt 2026-03-23 16:19:18 -07:00
parent 1aa8e4d7e8
commit ec23775205
42 changed files with 6976 additions and 2753 deletions

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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). |

View file

@ -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_

View file

@ -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)
# ---------------------------

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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.")

View file

@ -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
# ----------------------------------------------------------------------------------

View file

@ -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

View file

@ -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"])

View file

@ -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)

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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")

View file

@ -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}"'},
)

111
code/web/routes/price.py Normal file
View file

@ -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 namefloat|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),
})

View file

@ -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,
})

View file

@ -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]

View file

@ -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,

View file

@ -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"}],
})

View file

@ -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)

View file

@ -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 (023) 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

View file

@ -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; }

View file

@ -122,6 +122,7 @@ interface PointerEventLike {
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">' +
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">&nbsp;</div>' +
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>' +
'<div class="hcp-price" style="font-size:12px;font-weight:500;white-space:nowrap;"></div>' +
'<button type="button" class="hcp-close" aria-label="Close card details"><span aria-hidden="true">✕</span></button>' +
'</div>' +
'<div class="hcp-body">' +
@ -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 ? ' <span style="color:#f59e0b;font-size:10px;" title="Price may be outdated (>24h)">\u23F1</span>' : '')
: '';
}
const roleLabel = displayLabel(role);
const roleKey = (roleLabel || role || '').toLowerCase();

View file

@ -118,6 +118,7 @@
Card images and data provided by
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
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 %}<br><span class="muted" style="font-size:.8em;">Prices as of {{ _pba }} — for live pricing visit <a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.</span>{% endif %}
</footer>
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
<style>
@ -388,5 +389,164 @@
{% endif %}
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
<!-- Hover card panel system moved to cardHover.ts -->
<!-- Price tooltip: lightweight fetch on mouseenter for .card-name-price-hover -->
<script>
(function(){
var _priceCache = {};
var _tip = null;
function _showTip(el, text) {
if (!_tip) {
_tip = document.createElement('div');
_tip.className = 'card-price-tip';
document.body.appendChild(_tip);
}
_tip.textContent = text;
var r = el.getBoundingClientRect();
_tip.style.left = (r.left + r.width/2 + window.scrollX) + 'px';
_tip.style.top = (r.top + window.scrollY - 4) + 'px';
_tip.style.transform = 'translate(-50%, -100%)';
_tip.style.display = 'block';
}
function _hideTip() { if (_tip) _tip.style.display = 'none'; }
document.addEventListener('mouseover', function(e) {
var el = e.target && e.target.closest && e.target.closest('.card-name-price-hover');
if (!el) return;
var name = el.dataset.cardName || el.textContent.trim();
if (!name) return;
if (_priceCache[name] !== undefined) {
_showTip(el, _priceCache[name]);
return;
}
_showTip(el, 'Loading price...');
fetch('/api/price/' + encodeURIComponent(name))
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(d){
var label = (d && d.found && d.price != null) ? ('$' + parseFloat(d.price).toFixed(2)) : 'Price unavailable';
_priceCache[name] = label;
_showTip(el, label);
})
.catch(function(){ _priceCache[name] = 'Price unavailable'; });
});
document.addEventListener('mouseout', function(e) {
var el = e.target && e.target.closest && e.target.closest('.card-name-price-hover');
if (el) _hideTip();
});
})();
</script>
<!-- Budget price display: injects prices on card tiles and list rows, tracks running total -->
<script>
(function(){
var BASIC_LANDS = new Set([
'Plains','Island','Swamp','Mountain','Forest','Wastes',
'Snow-Covered Plains','Snow-Covered Island','Snow-Covered Swamp',
'Snow-Covered Mountain','Snow-Covered Forest'
]);
var _priceNum = {}; // card name -> float|null
var _deckPrices = {}; // accumulated across build stages: card name -> float
var _buildToken = null;
function _fetchNum(name) {
if (_priceNum.hasOwnProperty(name)) return Promise.resolve(_priceNum[name]);
return fetch('/api/price/' + encodeURIComponent(name))
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(d){
var p = (d && d.found && d.price != null) ? parseFloat(d.price) : null;
_priceNum[name] = p; return p;
}).catch(function(){ _priceNum[name] = null; return null; });
}
function _getBuildToken() {
var el = document.querySelector('[data-build-id]');
return el ? el.getAttribute('data-build-id') : null;
}
function _cfg() { return window._budgetCfg || null; }
function initPriceDisplay() {
var tok = _getBuildToken();
if (tok !== null && tok !== _buildToken) { _buildToken = tok; _deckPrices = {}; }
var cfg = _cfg();
var ceiling = cfg && cfg.card_ceiling ? parseFloat(cfg.card_ceiling) : null;
var totalCap = cfg && cfg.total ? parseFloat(cfg.total) : null;
function updateRunningTotal(prevTotal) {
var chip = document.getElementById('budget-running');
if (!chip) return;
var total = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
chip.textContent = total.toFixed(2);
var chipWrap = chip.closest('.chip');
if (chipWrap && totalCap !== null) chipWrap.classList.toggle('chip-warn', total > totalCap);
if (prevTotal !== undefined) {
var stepAdded = total - prevTotal;
var stepEl = document.getElementById('budget-step');
if (stepEl && stepAdded > 0.005) {
stepEl.textContent = '+$' + stepAdded.toFixed(2) + ' this step';
stepEl.style.display = '';
}
}
}
var overlays = document.querySelectorAll('.card-price-overlay[data-price-for]');
var inlines = document.querySelectorAll('.card-price-inline[data-price-for]');
var toFetch = new Set();
overlays.forEach(function(el){ var n = el.dataset.priceFor; if (n && !BASIC_LANDS.has(n)) toFetch.add(n); });
inlines.forEach(function(el){ var n = el.dataset.priceFor; if (n && !BASIC_LANDS.has(n)) toFetch.add(n); });
// Always refresh the running total chip even when there's nothing new to fetch
updateRunningTotal();
if (!toFetch.size) return;
var prevTotal = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
var promises = [];
toFetch.forEach(function(name){ promises.push(_fetchNum(name).then(function(p){ return {name:name,price:p}; })); });
Promise.all(promises).then(function(results){
var map = {};
var prevTotal2 = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
results.forEach(function(r){ map[r.name] = r.price; if (r.price !== null) _deckPrices[r.name] = r.price; });
overlays.forEach(function(el){
var name = el.dataset.priceFor;
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
var p = map[name];
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
if (ceiling !== null && p !== null && p > ceiling) {
var tile = el.closest('.card-tile,.stack-card');
if (tile) tile.classList.add('over-budget');
}
});
inlines.forEach(function(el){
var name = el.dataset.priceFor;
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
var p = map[name];
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
if (ceiling !== null && p !== null && p > ceiling) {
var row = el.closest('.list-row');
if (row) row.classList.add('over-budget');
}
});
// Update running total chip with per-step delta
updateRunningTotal(prevTotal2);
// Update summary budget bar
var bar = document.getElementById('budget-summary-bar');
if (bar) {
var allNames = new Set();
var sumTotal = 0;
document.querySelectorAll('.card-price-overlay[data-price-for],.card-price-inline[data-price-for]').forEach(function(el){
var n = el.dataset.priceFor;
if (n && !BASIC_LANDS.has(n) && !allNames.has(n)) {
allNames.add(n);
sumTotal += (map[n] || 0);
}
});
if (totalCap !== null) {
var over = sumTotal > totalCap;
bar.textContent = 'Estimated deck cost: $' + sumTotal.toFixed(2) + ' / $' + totalCap.toFixed(2) + (over ? ' — over budget' : ' — under budget');
bar.className = over ? 'budget-price-bar over' : 'budget-price-bar under';
} else {
bar.textContent = 'Estimated deck cost: $' + sumTotal.toFixed(2) + ' (basic lands excluded)';
bar.className = 'budget-price-bar';
}
}
});
}
document.addEventListener('DOMContentLoaded', function(){ initPriceDisplay(); });
document.addEventListener('htmx:afterSwap', function(){ setTimeout(initPriceDisplay, 80); });
})();
</script>
</body>
</html>

View file

@ -32,13 +32,14 @@
{% if it.rarity %}data-rarity="{{ it.rarity }}"{% endif %}
{% if it.hover_simple %}data-hover-simple="1"{% endif %}
{% if it.owned %}data-owned="1"{% endif %}
{% if it.price %}data-price="{{ it.price }}"{% endif %}
data-tags="{{ tags|join(', ') }}"
hx-post="/build/replace"
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}", "owned_only":"{{ 1 if require_owned else 0 }}"}'
hx-target="closest .alts"
hx-swap="outerHTML"
title="Lock this alternative and unlock the current pick">
{{ it.name }}
{{ it.name }}{% if it.price %} <span style="font-size:11px;opacity:.7;font-weight:normal;">${{ "%.2f"|format(it.price|float) }}</span>{% endif %}
</button>
</li>
{% endfor %}

View file

@ -0,0 +1,79 @@
{% if budget_review_visible %}
<div id="budget-review-panel" class="budget-review-panel mt-4">
<div class="budget-review-header">
<strong>Budget Review</strong>
<span class="budget-review-summary">
Your deck costs <strong>${{ '%.2f'|format(budget_review_total|float) }}</strong>
{% if over_budget_review %}
— {{ budget_overage_pct }}% over your ${{ '%.2f'|format(budget_review_cap|float) }} cap.
{% else %}
— within your ${{ '%.2f'|format(budget_review_cap|float) }} cap.
{% endif %}
</span>
{% if over_budget_review %}
<span class="chip chip-yellow">Advisory: deck is over budget</span>
{% else %}
<span class="chip chip-green">Within budget</span>
{% endif %}
</div>
{% if over_budget_cards %}
<div class="budget-review-cards">
<p class="muted budget-review-subtitle">Most expensive cards — swapping saves the most on total cost:</p>
{% for card in over_budget_cards %}
<div class="budget-review-card-row">
<div class="budget-review-card-info">
<span class="budget-review-card-name"
data-card-name="{{ card.name }}"
data-role="{{ card.card_role }}"
data-tags="{{ card.card_tags|join(', ') if card.card_tags else '' }}"
style="cursor:default;">{{ card.name }}</span>
<span class="budget-review-card-price">${{ '%.2f'|format(card.price|float) }}</span>{% if stale_prices is defined and card.name|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">&#x23F1;</span>{% endif %}
{% if card.card_type %}
<span class="chip chip-subtle text-xs" title="Card type">{{ card.card_type }}</span>
{% endif %}
{% if card.card_role %}
<span class="chip chip-subtle text-xs" title="Role in deck">{{ card.card_role }}</span>
{% endif %}
{% if card.swap_disabled %}
<span class="chip" title="This card is in your include list">Required</span>
{% endif %}
</div>
{% if not card.swap_disabled and card.alternatives %}
<div class="budget-review-alts">
<span class="muted">Swap for:</span>
{% for alt in card.alternatives %}
<form hx-post="/build/budget-swap"
hx-target="#budget-review-panel"
hx-swap="outerHTML"
class="inline-form">
<input type="hidden" name="old_card" value="{{ card.name }}" />
<input type="hidden" name="new_card" value="{{ alt.name }}" />
<button type="submit" class="btn-alt-swap"
data-card-name="{{ alt.name }}"
data-hover-simple="1"
{% if alt.price %}data-price="{{ alt.price }}"{% endif %}
title="{{ alt.shared_tags|join(', ') if alt.shared_tags else '' }}">
{{ alt.name }}
{% if alt.price %}<span class="alt-price">${{ '%.2f'|format(alt.price|float) }}</span>{% endif %}
</button>
</form>
{% endfor %}
</div>
{% elif not card.swap_disabled %}
<div class="muted budget-review-no-alts">No affordable alternatives found</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="muted">Price data unavailable for over-budget cards.</p>
{% endif %}
<div class="budget-review-actions mt-2">
<button type="button"
onclick="document.getElementById('budget-review-panel').remove()"
class="btn">Accept deck as-is</button>
</div>
</div>
{% endif %}

View file

@ -208,6 +208,34 @@
{% endif %}
{% endif %}
{% include "build/_new_deck_skip_controls.html" %}
{% if enable_budget_mode %}
<fieldset>
<legend>Budget</legend>
<div class="flex flex-col gap-3">
<label class="block">
<span>Total budget ($)</span>
<small class="muted block text-xs mt-1">Set a deck cost ceiling — cards over budget will be flagged</small>
<input type="number" name="budget_total" id="budget_total" min="0" step="0.01"
placeholder="e.g. 150.00"
value="{{ form.budget_total if form and form.budget_total else '' }}" />
</label>
<label class="block">
<span>Per-card ceiling ($) <small class="muted">(optional)</small></span>
<small class="muted block text-xs mt-1">Flag individual cards above this price</small>
<input type="number" name="card_ceiling" id="card_ceiling" min="0" step="0.01"
placeholder="e.g. 10.00"
value="{{ form.card_ceiling if form and form.card_ceiling else '' }}" />
</label>
<label class="block">
<span>Pool filter tolerance (%)</span>
<small class="muted block text-xs mt-1">Cards exceeding the per-card ceiling by more than this % are excluded from the card pool. Set to 0 to hard-cap at the ceiling exactly.</small>
<input type="number" name="pool_tolerance" id="pool_tolerance" min="0" max="100" step="1"
value="{{ form.pool_tolerance if form is not none else '15' }}" />
</label>
</div>
</fieldset>
{% endif %}
{% if enable_batch_build %}
<fieldset>
<legend>Build Options</legend>
@ -238,7 +266,7 @@
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<div class="modal-footer-left">
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
<button type="submit" class="btn-continue" id="create-btn">Create</button>
<button type="submit" class="btn-continue" id="create-btn">Build Deck</button>
</div>
</div>
{% if allow_must_haves and multi_copy_archetypes_js %}

View file

@ -1,6 +1,6 @@
{# Quick Build Progress Indicator - Current Stage + Completed List #}
<div id="wizard" class="wizard-container" style="max-width:1200px; margin:2rem auto; padding:2rem;">
<div class="wizard-container" style="max-width:1200px; margin:2rem auto; padding:2rem;">
<div id="wizard-content">
{% include "build/_quick_build_progress_content.html" %}
</div>

View file

@ -20,7 +20,7 @@
{% set hover_tags_joined = hover_tags_source|join(', ') %}
{% set display_tags = display_tags_source if display_tags_source else [] %}
{% set show_color_identity = color_label or (color_identity_list|length > 0) %}
<section>
<section data-build-id="{{ build_id }}">
{# Step phases removed #}
<div class="two-col two-col-left-rail">
<aside class="card-preview">
@ -186,6 +186,10 @@
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
{% endif %}
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
{% if budget_config and budget_config.total %}
<span class="chip" id="budget-chip"><span class="dot dot-yellow"></span> $<span id="budget-running">...</span> / ${{ '%.2f'|format(budget_config.total|float) }} cap</span>
<span id="budget-step" class="muted text-xs" style="display:none"></span>
{% endif %}
</div>
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
@ -214,6 +218,67 @@
<div hx-get="/build/compliance" hx-trigger="load" hx-swap="afterend"></div>
{% if status and status.startswith('Build complete') %}
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
{# M5: Budget review panel — shown in main content when deck total exceeds cap #}
{% include 'build/_budget_review.html' %}
{# M8: Price charts accordion — shown when budget mode was enabled and prices loaded #}
{% if (price_category_chart and price_category_chart.total > 0) or price_histogram_chart %}
<details class="analytics-accordion mt-4" id="price-charts-accordion">
<summary class="combo-summary">
<span>Price Breakdown</span>
<span class="muted text-xs font-normal ml-2">spend by category &amp; distribution</span>
</summary>
<div class="analytics-content mt-3">
{% if price_category_chart and price_category_chart.total > 0 %}
<div class="price-cat-section">
<div class="price-cat-heading">Spend by Category — ${{ '%.2f'|format(price_category_chart.total) }} total</div>
<div class="price-cat-bar" title="Total: ${{ '%.2f'|format(price_category_chart.total) }}">
{% for cat in price_category_chart.order %}
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
{% if cat_total > 0 %}
{% set pct = (cat_total * 100 / price_category_chart.total) | round(1) %}
<div class="price-cat-seg"
style="width:{{ pct }}%; background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"
title="{{ cat }}: ${{ '%.2f'|format(cat_total) }} ({{ pct }}%)"></div>
{% endif %}
{% endfor %}
</div>
<div class="price-cat-legend">
{% for cat in price_category_chart.order %}
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
{% if cat_total > 0 %}
<span class="price-cat-legend-item">
<span class="price-cat-swatch" style="background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"></span>
{{ cat }} ${{ '%.2f'|format(cat_total) }}
</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% if price_histogram_chart %}
<div class="price-hist-section">
<div class="price-hist-heading">Price Distribution</div>
<div class="price-hist-bars">
{% for bin in price_histogram_chart %}
<div class="price-hist-column"
data-type="hist"
data-range="${{ '%.2f'|format(bin.range_min) }}${{ '%.2f'|format(bin.range_max) }}"
data-val="{{ bin.count }}"
data-cards="{% for c in bin.cards %}{{ c.name }}|{{ '%.2f'|format(c.price) }}{% if not loop.last %} • {% endif %}{% endfor %}">
<div class="price-hist-bar" style="height:{{ bin.pct | default(0) }}%; background:{{ bin.color }};"></div>
</div>
{% endfor %}
</div>
<div class="price-hist-xlabels">
{% for bin in price_histogram_chart %}
<div class="price-hist-xlabel">{{ bin.x_label }}</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</details>
{% endif %}
{% endif %}
{% if locked_cards is defined and locked_cards %}
@ -238,7 +303,8 @@
<!-- Last action chip (oob-updated) -->
<div id="last-action" aria-live="polite" class="my-1 last-action"></div>
<!-- Filters toolbar -->
{% if not (status and status.startswith('Build complete')) %}
<!-- Filters toolbar (only during active build stages) -->
<div class="cards-toolbar">
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
<select name="filter_owned" data-pref="cards:owned">
@ -267,21 +333,37 @@
<span class="chip" data-chip-clear>Clear</span>
</div>
</div>
{% endif %}
{% if status and status.startswith('Build complete') %}
<!-- Minimal controls at build complete -->
<div class="build-controls">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
</form>
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
</form>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
</div>
{% else %}
<!-- Sticky build controls on mobile -->
<div class="build-controls">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
</form>
{% if not (status and status.startswith('Build complete')) %}
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Continuing…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-continue" data-action="continue" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Continue</button>
<button type="submit" class="btn-continue" data-action="continue" {% if gated %}disabled{% endif %}>Continue</button>
</form>
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" class="btn-rerun" data-action="rerun" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Rerun Stage</button>
<button type="submit" class="btn-rerun" data-action="rerun" {% if gated %}disabled{% endif %}>Rerun Stage</button>
</form>
{% endif %}
<span class="sep"></span>
<div class="replace-toggle" role="group" aria-label="Replace toggle">
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" class="inline-form">
@ -306,6 +388,7 @@
</label>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
</div>
{% endif %}
{% if added_cards is not none %}
{% if history is defined and history %}
@ -333,7 +416,9 @@
<span><span class="ownership-badge"></span> Owned</span>
<span><span class="ownership-badge"></span> Not owned</span>
</div>
{% if stale_prices_global is defined and stale_prices_global %}
<div class="stale-banner">&#x23F1; Prices shown may be more than 24 hours old. Refresh price data on the Setup page if you need current values.</div>
{% endif %}
{% if stage_label and stage_label.startswith('Creatures') %}
{% set groups = added_cards|groupby('sub_role') %}
{% for g in groups %}
@ -356,13 +441,17 @@
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}"
data-price="{{ (price_cache or {}).get(c.name.lower(), {}).get('usd') or '' }}"
data-stale="{% if stale_prices is defined and c.name|lower in stale_prices %}1{% else %}0{% endif %}">
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
sizes="160px" />
</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div>
{% if stale_prices is defined and c.name|lower in stale_prices %}<div class="stale-price-indicator" title="Price may be outdated (>24h)">&#x23F1;</div>{% endif %}
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
{% from 'partials/_macros.html' import lock_button %}
@ -402,13 +491,17 @@
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}"
data-price="{{ (price_cache or {}).get(c.name.lower(), {}).get('usd') or '' }}"
data-stale="{% if stale_prices is defined and c.name|lower in stale_prices %}1{% else %}0{% endif %}">
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
sizes="160px" />
</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div>
{% if stale_prices is defined and c.name|lower in stale_prices %}<div class="stale-price-indicator" title="Price may be outdated (>24h)">&#x23F1;</div>{% endif %}
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
<div class="lock-box" id="lock-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
{% from 'partials/_macros.html' import lock_button %}
@ -463,6 +556,9 @@
{% set oob = False %}
{% include "partials/include_exclude_summary.html" %}
{% endif %}
{% if budget_config and budget_config.total %}
<script>window._budgetCfg={"total":{{ budget_config.total|float }},"card_ceiling":{{ budget_config.card_ceiling|float if budget_config.card_ceiling else 'null' }}};</script>
{% endif %}
<div id="deck-summary" data-summary
hx-get="/build/step5/summary?token={{ summary_token }}"
hx-trigger="load once, step5:refresh from:body"

View file

@ -62,8 +62,8 @@
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
<span class="muted" style="font-size:12px;">Select</span>
</label>
<form action="/files" method="get" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ it.path }}" />
<form action="/decks/download-csv" method="get" style="display:inline; margin:0;">
<input type="hidden" name="name" value="{{ it.name }}" />
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
</form>
{% if it.txt_path %}

View file

@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block banner_subtitle %}Budget Pickups{% endblock %}
{% block content %}
<h2>Pickups List</h2>
{% if commander %}
<div class="muted" style="margin-bottom:.5rem;">Deck: <strong>{{ commander }}</strong>{% if name %} — <span class="muted text-xs">{{ name }}</span>{% endif %}</div>
{% endif %}
{% if error %}
<div class="panel panel-info-warning">{{ error }}</div>
{% elif not budget_report %}
<div class="panel">No budget report available for this deck.</div>
{% else %}
{% set bstatus = budget_report.budget_status %}
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-bottom:.75rem;">
{% if bstatus == 'under' %}
Under Budget: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
{% elif bstatus == 'soft_exceeded' %}
Over Budget (soft): ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
(+${{ "%.2f"|format(budget_report.overage) }})
{% else %}
Hard Cap Exceeded: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
(+${{ "%.2f"|format(budget_report.overage) }})
{% endif %}
</div>
{% if stale_prices_global is defined and stale_prices_global %}
<div class="stale-banner">&#x23F1; Prices shown may be more than 24 hours old. Refresh price data on the Setup page if you need current values.</div>
{% endif %}
{% if budget_report.pickups_list %}
<p class="muted text-sm" style="margin-bottom:.5rem;">
Cards you don't own yet that fit the deck's themes and budget. Sorted by theme match priority.
</p>
<table class="pickups-table" style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Card</th>
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Price</th>
<th style="text-align:center; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Tier</th>
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Priority</th>
</tr>
</thead>
<tbody>
{% for card in budget_report.pickups_list %}
<tr>
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
<span class="card-name-price-hover" data-card-name="{{ card.card|e }}">{{ card.card }}</span>
</td>
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
{% if card.price is not none %}
${{ "%.2f"|format(card.price) }}{% if stale_prices is defined and card.card|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">&#x23F1;</span>{% endif %}
{% else %}
<span class="muted"></span>
{% endif %}
</td>
<td style="text-align:center; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
<span class="tier-badge tier-badge--{{ card.tier|lower }}">{{ card.tier }}</span>
</td>
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);" class="muted">
{{ card.priority }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="panel muted">No pickups suggestions available — your deck may already fit the budget well.</div>
{% endif %}
{% if budget_report.over_budget_cards %}
<h3 style="margin-top:1.25rem;">Over-Budget Cards</h3>
<p class="muted text-sm" style="margin-bottom:.5rem;">
Cards in your current deck that exceed the budget or per-card ceiling.
</p>
<table style="width:100%; border-collapse:collapse;">
<thead>
<tr>
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Card</th>
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Price</th>
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Note</th>
</tr>
</thead>
<tbody>
{% for c in budget_report.over_budget_cards %}
<tr>
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">{{ c.card }}</td>
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">${{ "%.2f"|format(c.price) }}{% if stale_prices is defined and c.card|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">&#x23F1;</span>{% endif %}</td>
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);" class="muted">
{% if c.ceiling_exceeded %}Above ${{ "%.2f"|format(budget_config.card_ceiling) }} ceiling{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endif %}
<div style="margin-top:1rem;">
<a href="/decks/view?name={{ name|urlencode }}" class="btn" role="button">Back to Deck</a>
<a href="/decks" class="btn" role="button">All Decks</a>
</div>
{% endblock %}

View file

@ -56,8 +56,8 @@
{% endif %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
{% if csv_path %}
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="path" value="{{ csv_path }}" />
<form action="/decks/download-csv" method="get" target="_blank" style="display:inline; margin:0;">
<input type="hidden" name="name" value="{{ name }}" />
<button type="submit">Download CSV</button>
</form>
{% endif %}
@ -68,10 +68,37 @@
</form>
{% endif %}
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
{% if budget_report %}
<a href="/decks/pickups?name={{ name|urlencode }}" class="btn" role="button" title="View cards to acquire for this budget build">Pickups List</a>
{% endif %}
<form method="get" action="/decks" style="display:inline; margin:0;">
<button type="submit">Back to Finished Decks</button>
</form>
</div>
{% if budget_report %}
{% set bstatus = budget_report.budget_status %}
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-top:.6rem;">
{% if bstatus == 'under' %}
Under Budget: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
{% elif bstatus == 'soft_exceeded' %}
Over Budget (soft): ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
(+${{ "%.2f"|format(budget_report.overage) }})
{% else %}
Hard Cap Exceeded: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
(+${{ "%.2f"|format(budget_report.overage) }})
{% endif %}
</div>
{% if budget_report.over_budget_cards %}
<div class="panel panel-info-warning" style="margin-top:.5rem;">
<strong>Cards over budget:</strong>
<ul class="muted" style="margin:.25rem 0 0 1rem; padding:0; font-size:.85em;">
{% for c in budget_report.over_budget_cards %}
<li>{{ c.card }} — ${{ "%.2f"|format(c.price) }}{% if c.ceiling_exceeded %} (above ${{ "%.2f"|format(budget_config.card_ceiling) }} ceiling){% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endif %}
</aside>
<div class="grow">
{% if summary %}
@ -99,6 +126,67 @@
</div>
{% endif %}
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
{# M8: Price charts accordion — placed in main area, only available on the saved deck view #}
{% if (price_category_chart and price_category_chart.total > 0) or price_histogram_chart %}
<section class="summary-section-lg">
<details class="analytics-accordion" id="price-charts-accordion">
<summary class="combo-summary">
<span>Price Breakdown</span>
<span class="muted text-xs font-normal ml-2">spend by category &amp; distribution</span>
</summary>
<div class="analytics-content mt-3">
{% if price_category_chart and price_category_chart.total > 0 %}
<div class="price-cat-section">
<div class="price-cat-heading">Spend by Category — ${{ '%.2f'|format(price_category_chart.total) }} total</div>
<div class="price-cat-bar" title="Total: ${{ '%.2f'|format(price_category_chart.total) }}">
{% for cat in price_category_chart.order %}
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
{% if cat_total > 0 %}
{% set pct = (cat_total * 100 / price_category_chart.total) | round(1) %}
<div class="price-cat-seg"
style="width:{{ pct }}%; background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"
title="{{ cat }}: ${{ '%.2f'|format(cat_total) }} ({{ pct }}%)"></div>
{% endif %}
{% endfor %}
</div>
<div class="price-cat-legend">
{% for cat in price_category_chart.order %}
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
{% if cat_total > 0 %}
<span class="price-cat-legend-item">
<span class="price-cat-swatch" style="background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"></span>
{{ cat }} ${{ '%.2f'|format(cat_total) }}
</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% if price_histogram_chart %}
<div class="price-hist-section">
<div class="price-hist-heading">Price Distribution</div>
<div class="price-hist-bars">
{% for bin in price_histogram_chart %}
<div class="price-hist-column"
data-type="hist"
data-range="${{ '%.2f'|format(bin.range_min) }}${{ '%.2f'|format(bin.range_max) }}"
data-val="{{ bin.count }}"
data-cards="{% for c in bin.cards %}{{ c.name }}|{{ '%.2f'|format(c.price) }}{% if not loop.last %} • {% endif %}{% endfor %}">
<div class="price-hist-bar" style="height:{{ bin.pct | default(0) }}%; background:{{ bin.color }};"></div>
</div>
{% endfor %}
</div>
<div class="price-hist-xlabels">
{% for bin in price_histogram_chart %}
<div class="price-hist-xlabel">{{ bin.x_label }}</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</details>
</section>
{% endif %}
{% else %}
<div class="muted">No summary available.</div>
{% endif %}

View file

@ -1,4 +1,7 @@
<div id="deck-summary" data-summary>
{% if budget_config and budget_config.total %}
<script>window._budgetCfg={"total":{{ budget_config.total|float }},"card_ceiling":{{ budget_config.card_ceiling|float if budget_config.card_ceiling else 'null' }}};</script>
{% endif %}
<hr class="summary-divider" />
<h4>Deck Summary</h4>
<section class="summary-section">
@ -35,6 +38,9 @@
.owned-flag { font-size:.95rem; opacity:.9; }
</style>
<div id="typeview-list" class="typeview">
{% if budget_config and budget_config.total %}
<div id="budget-summary-bar" class="budget-price-bar" aria-live="polite">Loading deck cost...</div>
{% endif %}
{% for t in tb.order %}
<div class="summary-type-heading">
{{ 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 @@
<span class="count">{{ cnt }}</span>
<span class="times">x</span>
<span class="name dfc-anchor" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if c.metadata_tags %} data-metadata-tags="{{ (c.metadata_tags|map('trim')|join(', ')) }}"{% endif %}{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>{{ c.name }}</span>
<span class="card-price-inline" data-price-for="{{ c.name }}"></span>
<span class="flip-slot" aria-hidden="true">
{% if c.dfc_land %}
<span class="dfc-land-chip {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}</span>
@ -114,8 +121,7 @@
<img class="card-thumb" loading="lazy" decoding="async" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
sizes="(max-width: 1200px) 160px, 240px" />
<div class="count-badge">{{ cnt }}x</div>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="count-badge">{{ cnt }}x</div> <div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div> <div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
{% if c.dfc_land %}
<div class="dfc-thumb-badge {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC{% if c.dfc_adds_extra_land %}+1{% endif %}</div>
{% endif %}
@ -601,6 +607,11 @@
} else if (t === 'curve') {
titleSpan.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else if (t === 'hist') {
var hval = el.dataset.val || '0';
titleSpan.textContent = (el.dataset.range || '') + ' \u2014 ' + hval + ' card' + (hval !== '1' ? 's' : '');
var pairs = (el.dataset.cards || '').split(' \u2022 ').filter(Boolean);
listText = pairs.map(function(p){ var idx = p.lastIndexOf('|'); return idx < 0 ? p : p.slice(0, idx) + ' \u2014 $' + parseFloat(p.slice(idx+1)).toFixed(2); }).join('\n');
} else {
titleSpan.textContent = el.getAttribute('aria-label') || '';
}
@ -662,6 +673,8 @@
var s = String(n);
// Strip trailing " ×<num>" count suffix if present
s = s.replace(/\s×\d+$/,'');
// Strip trailing "|price" suffix from hist bars
s = s.replace(/\|[\d.]+$/, '');
return s.trim();
}).filter(Boolean);
}
@ -707,8 +720,8 @@
}
function attach() {
// Attach to SVG elements with data-type for better hover zones
document.querySelectorAll('svg[data-type]').forEach(function(el) {
// Attach to elements with data-type (SVG mana charts + div hist bars)
document.querySelectorAll('[data-type]').forEach(function(el) {
el.addEventListener('mouseenter', function(e) {
// Don't show hover tooltip if this element is pinned
if (pinnedEl === el) return;
@ -719,7 +732,7 @@
// Cross-highlight for mana curve bars -> card items
try {
var dataType = el.getAttribute('data-type');
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources') {
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources' || dataType === 'hist') {
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
lastType = dataType;
// Only apply hover highlights if nothing is pinned
@ -769,7 +782,7 @@
document.addEventListener('click', function(e) {
if (!pinnedEl) return;
// Don't unpin if clicking the tooltip itself or a chart
if (tip.contains(e.target) || e.target.closest('svg[data-type]')) return;
if (tip.contains(e.target) || e.target.closest('[data-type]')) return;
unpin();
});
@ -825,7 +838,16 @@
}
} catch(_) {}
}
attach();
// On static pages (view.html, run_result.html) deck_summary.html is rendered
// before the price-chart histogram bars in the outer template, so the inline
// script runs mid-parse and querySelectorAll('[data-type]') would not yet see
// those elements. Deferring to DOMContentLoaded fixes this for static pages
// while still running immediately when injected via HTMX (readyState 'complete').
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() { attach(); });
} else {
attach();
}
document.addEventListener('htmx:afterSwap', function() { attach(); });
})();
</script>

View file

@ -145,6 +145,25 @@
<span class="muted" style="align-self:center; font-size:.85rem;">(~15-20 min local, instant if cached on GitHub)</span>
</div>
{% endif %}
<details style="margin-top:1.25rem;" open>
<summary>Card Price Cache Status</summary>
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); border-radius:8px;">
<div class="muted">Last updated:</div>
<div style="margin-top:.25rem;" id="price-cache-built-at">
{% if price_cache_built_at %}{{ price_cache_built_at }}{% else %}<span class="muted">Not yet generated — run Setup first, then refresh prices.</span>{% endif %}
</div>
{% if price_auto_refresh %}
<div class="muted" style="margin-top:.5rem; font-size:.85rem;">Auto-refresh is enabled (runs daily at 01:00 UTC).</div>
{% endif %}
</div>
</details>
{% if not price_auto_refresh %}
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
{{ button('Refresh Card Prices', variant='primary', onclick='refreshPriceCache()', attrs='id="btn-refresh-prices"') }}
<span class="muted" style="align-self:center; font-size:.85rem;">Rebuilds price data from local Scryfall bulk data (requires Setup to have run).</span>
</div>
{% endif %}
</section>
<script>
(function(){
@ -620,6 +639,33 @@
setInterval(pollSimilarityStatus, 10000); // Poll every 10s
{% endif %}
window.refreshPriceCache = function(){
var btn = document.getElementById('btn-refresh-prices');
if (btn) { btn.disabled = true; btn.textContent = 'Refreshing…'; }
fetch('/api/price/refresh', { method: 'POST' })
.then(function(r){ return r.json(); })
.then(function(data){
if (btn) { btn.textContent = 'Refresh started — check logs for progress.'; }
// Update timestamp line after a short delay to let the rebuild start
setTimeout(function(){
fetch('/api/price/stats')
.then(function(r){ return r.json(); })
.then(function(s){
var el = document.getElementById('price-cache-built-at');
if (el && s.last_refresh) {
var d = new Date(s.last_refresh * 1000);
el.textContent = d.toLocaleDateString('en-US', { year:'numeric', month:'long', day:'numeric' });
}
if (btn) { btn.disabled = false; btn.textContent = 'Refresh Card Prices'; }
})
.catch(function(){ if (btn) { btn.disabled = false; btn.textContent = 'Refresh Card Prices'; } });
}, 3000);
})
.catch(function(){
if (btn) { btn.disabled = false; btn.textContent = 'Refresh failed'; setTimeout(function(){ btn.textContent = 'Refresh Card Prices'; }, 2000); }
});
};
// Initialize image status polling
pollImageStatus();
setInterval(pollImageStatus, 10000); // Poll every 10s

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,11 @@ services:
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
ENABLE_BUDGET_MODE: "1" # 1=enable budget mode controls and reporting in the UI
BUDGET_POOL_TOLERANCE: "0.15" # Fractional overhead above per-card ceiling before a card is excluded from pool (e.g. 0.15 = 15%)
PRICE_AUTO_REFRESH: "0" # 1=rebuild price cache daily at 01:00 UTC
PRICE_LAZY_REFRESH: "1" # 1=refresh stale per-card prices in background (7-day TTL)
PRICE_STALE_WARNING_HOURS: "24" # Hours before a cached card price is considered stale (shows ⏱ indicator); 0=disable
SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)

View file

@ -34,6 +34,11 @@ services:
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
SHOW_MUST_HAVE_BUTTONS: "0" # 1=show must include/exclude controls in the UI (default hidden)
ENABLE_BUDGET_MODE: "1" # 1=enable budget mode controls and reporting in the UI
BUDGET_POOL_TOLERANCE: "0.15" # Fractional overhead above per-card ceiling before a card is excluded from pool (e.g. 0.15 = 15%)
PRICE_AUTO_REFRESH: "0" # 1=rebuild price cache daily at 01:00 UTC
PRICE_LAZY_REFRESH: "1" # 1=refresh stale per-card prices in background (7-day TTL)
PRICE_STALE_WARNING_HOURS: "24" # Hours before a cached card price is considered stale (shows ⏱ indicator); 0=disable
SHOW_MISC_POOL: "0"
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
ENABLE_CARD_DETAILS: "1" # 1=show Card Details button in card browser (with similarity cache)