mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 05:56:32 +01:00
feat: add Budget Mode with price cache infrastructure and stale price warnings
This commit is contained in:
parent
1aa8e4d7e8
commit
ec23775205
42 changed files with 6976 additions and 2753 deletions
|
|
@ -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"
|
||||
|
|
|
|||
18
.github/workflows/build-similarity-cache.yml
vendored
18
.github/workflows/build-similarity-cache.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -15,9 +15,39 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md`
|
||||
- **Multi-copy / Include conflict dialog**: When a known multi-copy archetype card (e.g., Hare Apparent) is typed in the Must Include field of the New Deck modal, a popup now appears asking how many copies to include, with an optional Thrumming Stone checkbox
|
||||
- **Multi-copy / Exclude conflict dialog**: When a multi-copy archetype is selected via the Multi-Copy Package selector and the same card also appears in the Must Exclude field, a conflict popup lets you choose to keep the multi-copy (removing it from excludes) or keep the exclude (disabling the archetype selection)
|
||||
- **Budget Mode**: Full budget-aware deck building with price integration
|
||||
- Budget configuration on the New Deck modal: set a total budget cap, optional per-card ceiling, and soft/hard enforcement mode
|
||||
- Price display during building: card prices shown next to card names in list and thumbnail views throughout the build pipeline
|
||||
- Running budget counter chip updates as each build stage completes
|
||||
- Over-budget card highlight: cards exceeding the per-card ceiling are marked with a yellow/gold border
|
||||
- Basic lands excluded from all budget calculations
|
||||
- Budget summary bar in the deck summary view with under/over color coding
|
||||
- Budget badge and over-budget panel on the saved deck view
|
||||
- Pickups list page (`/decks/{name}/pickups`) sorted by priority tier
|
||||
- Pool budget filter: cards exceeding the per-card ceiling by more than the pool tolerance (default 15%, configurable per build in the New Deck modal) are excluded from the candidate pool before building begins
|
||||
- Card price shown in the hover and tap popup for all card tiles with a cached price
|
||||
- Price shown inline on each alternative card suggestion in the alternatives panel
|
||||
- Post-build budget review panel appears when the final deck total exceeds the budget cap by more than 10%; lists over-budget cards sorted by overage with up to 3 cheaper alternatives each
|
||||
- Alternatives in the review panel are matched by card type (lands suggest land alternatives, creatures suggest creature alternatives) and sorted by role similarity using shared strategy tags
|
||||
- Each alternative has a Swap button that replaces the card in the finalized deck and re-evaluates the budget live; the panel auto-dismisses when the total drops within tolerance
|
||||
- "Accept deck as-is" button in soft mode lets you bypass the review and proceed to export
|
||||
- Build complete screen shows a minimal action bar (Restart build / New build / Back) instead of the full stage ribbon
|
||||
- Controlled by `ENABLE_BUDGET_MODE` environment variable (default: enabled)
|
||||
- **Price Cache Infrastructure**: Improved price data lifecycle
|
||||
- `price` and `price_updated` columns added to parquet card database via `refresh_prices_parquet()`
|
||||
- `PRICE_AUTO_REFRESH=1`: optional daily 1 AM UTC scheduled price cache rebuild
|
||||
- `PRICE_LAZY_REFRESH=1`: background per-card price refresh for cards not updated in 7 days (default: enabled)
|
||||
- `POST /api/price/refresh`: manual price cache rebuild trigger
|
||||
- "Card Price Cache Status" section on the Setup page with last-updated date and Refresh button
|
||||
- Footer now shows the price data date alongside the Scryfall attribution
|
||||
- **Price charts**: Visual cost breakdown added to the deck summary and build complete screens
|
||||
- Donut/bar chart showing total deck spend by card role category (commander, ramp, card draw, lands, etc.)
|
||||
- Price histogram showing card count distribution across cost buckets
|
||||
- Basic lands excluded from all chart calculations
|
||||
- **Stale price warnings**: Cards with price data older than 24 hours are flagged with a subtle clock indicator (⏱) on card tiles, the hover popup, the budget review panel, and the Pickups page; if more than half the deck's prices are stale a single banner is shown instead of per-card indicators; controlled by `PRICE_STALE_WARNING_HOURS` (default: 24; set to 0 to disable)
|
||||
|
||||
### Changed
|
||||
_No unreleased changes yet_
|
||||
- **Create Button in New Dock Panel**: Button has been renamed to "Build Deck" for consistency with phrasing on the "Quick Build" button
|
||||
|
||||
### Fixed
|
||||
- **Multi-copy include count**: Typing an archetype card in Must Include no longer adds only 1 copy — the archetype count is now respected when the dialog is confirmed
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
|
|
|||
379
code/tests/test_budget_evaluator.py
Normal file
379
code/tests/test_budget_evaluator.py
Normal 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
|
||||
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
307
code/tests/test_price_service.py
Normal file
307
code/tests/test_price_service.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
111
code/web/routes/price.py
Normal 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 name→float|null) and ``missing`` (list
|
||||
of names with no price data).
|
||||
"""
|
||||
svc = get_price_service()
|
||||
prices = svc.get_prices_batch(card_names, region=region, foil=foil)
|
||||
missing = [n for n, p in prices.items() if p is None]
|
||||
return JSONResponse({
|
||||
"prices": prices,
|
||||
"missing": missing,
|
||||
"region": region,
|
||||
"foil": foil,
|
||||
"total": len(card_names),
|
||||
"found": len(card_names) - len(missing),
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
692
code/web/services/budget_evaluator.py
Normal file
692
code/web/services/budget_evaluator.py
Normal 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]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
504
code/web/services/price_service.py
Normal file
504
code/web/services/price_service.py
Normal 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 (0–23) at which to run the nightly rebuild.
|
||||
on_after_rebuild: Optional callable invoked after each successful
|
||||
rebuild (e.g., to update the parquet files).
|
||||
"""
|
||||
with self._lock:
|
||||
if getattr(self, "_daily_thread", None) and self._daily_thread.is_alive(): # type: ignore[attr-defined]
|
||||
return
|
||||
|
||||
def _loop() -> None:
|
||||
import datetime
|
||||
last_date: "datetime.date | None" = None
|
||||
while True:
|
||||
try:
|
||||
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
today = now.date()
|
||||
if now.hour >= hour and today != last_date:
|
||||
logger.info("Scheduled price refresh running (daily at %02d:00 UTC) …", hour)
|
||||
self._rebuild_cache()
|
||||
last_date = today
|
||||
if on_after_rebuild:
|
||||
try:
|
||||
on_after_rebuild()
|
||||
except Exception as exc:
|
||||
logger.error("on_after_rebuild callback failed: %s", exc)
|
||||
logger.info("Scheduled price refresh complete.")
|
||||
except Exception as exc:
|
||||
logger.error("Daily price refresh error: %s", exc)
|
||||
time.sleep(1800)
|
||||
|
||||
t = threading.Thread(target=_loop, daemon=True, name="price-daily-refresh")
|
||||
self._daily_thread = t # type: ignore[attr-defined]
|
||||
t.start()
|
||||
logger.info("Daily price refresh scheduler started (hour=%d UTC)", hour)
|
||||
|
||||
def start_lazy_refresh(self, stale_days: int = 7) -> None:
|
||||
"""Start a background worker that refreshes per-card prices from the
|
||||
Scryfall API when they have not been updated within *stale_days* days.
|
||||
|
||||
Queuing: call queue_lazy_refresh(card_name) to mark a card as stale.
|
||||
The worker runs every 60 seconds, processes up to 20 cards per cycle,
|
||||
and respects Scryfall's 100 ms rate-limit guideline.
|
||||
"""
|
||||
with self._lock:
|
||||
if getattr(self, "_lazy_thread", None) and self._lazy_thread.is_alive(): # type: ignore[attr-defined]
|
||||
return
|
||||
self._lazy_stale_seconds: float = stale_days * 86400
|
||||
self._lazy_queue: set[str] = set()
|
||||
self._lazy_ts: dict[str, float] = self._load_lazy_ts()
|
||||
self._lazy_lock = threading.Lock()
|
||||
|
||||
def _worker() -> None:
|
||||
while True:
|
||||
try:
|
||||
time.sleep(60)
|
||||
with self._lazy_lock:
|
||||
batch = list(self._lazy_queue)[:20]
|
||||
self._lazy_queue -= set(batch)
|
||||
if batch:
|
||||
self._fetch_lazy_batch(batch)
|
||||
except Exception as exc:
|
||||
logger.error("Lazy price refresh error: %s", exc)
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True, name="price-lazy-refresh")
|
||||
self._lazy_thread = t # type: ignore[attr-defined]
|
||||
t.start()
|
||||
logger.info("Lazy price refresh worker started (stale_days=%d)", stale_days)
|
||||
|
||||
def queue_lazy_refresh(self, card_name: str) -> None:
|
||||
"""Mark *card_name* for a lazy per-card price update if its cached
|
||||
price is stale or missing. No-op when lazy mode is not enabled."""
|
||||
if not hasattr(self, "_lazy_queue"):
|
||||
return
|
||||
key = card_name.lower().strip()
|
||||
ts = self._lazy_ts.get(key)
|
||||
if ts is None or (time.time() - ts) > self._lazy_stale_seconds:
|
||||
with self._lazy_lock:
|
||||
self._lazy_queue.add(card_name.strip())
|
||||
|
||||
def _fetch_lazy_batch(self, names: list[str]) -> None:
|
||||
"""Fetch fresh prices for *names* from the Scryfall named-card API."""
|
||||
import urllib.request as _urllib
|
||||
import urllib.parse as _urlparse
|
||||
now = time.time()
|
||||
updated: dict[str, float] = {}
|
||||
for name in names:
|
||||
try:
|
||||
url = "https://api.scryfall.com/cards/named?" + _urlparse.urlencode({"exact": name, "format": "json"})
|
||||
req = _urllib.Request(url, headers={"User-Agent": "MTGPythonDeckbuilder/1.0"})
|
||||
with _urllib.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
raw_prices: dict = data.get("prices") or {}
|
||||
entry = self._extract_prices(raw_prices)
|
||||
if entry:
|
||||
key = name.lower()
|
||||
with self._lock:
|
||||
self._cache[key] = entry
|
||||
updated[key] = now
|
||||
logger.debug("Lazy refresh: %s → $%.2f", name, entry.get("usd", 0))
|
||||
except Exception as exc:
|
||||
logger.debug("Lazy price fetch skipped for %s: %s", name, exc)
|
||||
time.sleep(0.1) # 100 ms — Scryfall rate-limit guideline
|
||||
if updated:
|
||||
self._lazy_ts.update(updated)
|
||||
self._save_lazy_ts()
|
||||
# Also persist the updated in-memory cache to the JSON cache file
|
||||
try:
|
||||
self._persist_cache_snapshot()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_lazy_ts(self) -> dict[str, float]:
|
||||
"""Load per-card timestamps from companion file."""
|
||||
ts_path = self._cache_path + ".ts"
|
||||
try:
|
||||
if os.path.exists(ts_path):
|
||||
with open(ts_path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_lazy_ts(self) -> None:
|
||||
"""Atomically persist per-card timestamps."""
|
||||
ts_path = self._cache_path + ".ts"
|
||||
tmp = ts_path + ".tmp"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(self._lazy_ts, fh, separators=(",", ":"))
|
||||
os.replace(tmp, ts_path)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save lazy timestamps: %s", exc)
|
||||
|
||||
def get_stale_cards(self, threshold_hours: int = 24) -> set[str]:
|
||||
"""Return the set of card names whose cached price is older than *threshold_hours*.
|
||||
|
||||
Uses the per-card timestamp sidecar (``prices_cache.json.ts``). If the
|
||||
sidecar is absent, all priced cards are considered stale (safe default).
|
||||
Returns an empty set when *threshold_hours* is 0 (warnings disabled).
|
||||
Card names are returned in their original (display-name) casing as stored
|
||||
in ``self._cache``.
|
||||
"""
|
||||
import time as _t
|
||||
if threshold_hours <= 0:
|
||||
return set()
|
||||
cutoff = _t.time() - threshold_hours * 3600
|
||||
with self._lock:
|
||||
ts_map: dict[str, float] = dict(self._lazy_ts)
|
||||
cached_keys: set[str] = set(self._cache.keys())
|
||||
stale: set[str] = set()
|
||||
for key in cached_keys:
|
||||
ts = ts_map.get(key)
|
||||
if ts is None or ts < cutoff:
|
||||
stale.add(key)
|
||||
return stale
|
||||
|
||||
def _persist_cache_snapshot(self) -> None:
|
||||
"""Write the current in-memory cache to the JSON cache file (atomic)."""
|
||||
import time as _t
|
||||
with self._lock:
|
||||
snapshot = dict(self._cache)
|
||||
built = self._last_refresh or _t.time()
|
||||
cache_data = {"prices": snapshot, "built_at": built}
|
||||
tmp_path = self._cache_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(cache_data, fh, separators=(",", ":"))
|
||||
os.replace(tmp_path, self._cache_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Lazy-load the price cache on first access (double-checked lock)."""
|
||||
if self._loaded:
|
||||
return
|
||||
with self._lock:
|
||||
if self._loaded:
|
||||
return
|
||||
self._load_or_rebuild()
|
||||
self._loaded = True
|
||||
|
||||
def _load_or_rebuild(self) -> None:
|
||||
"""Load from JSON cache if fresh; otherwise rebuild from bulk data."""
|
||||
if os.path.exists(self._cache_path):
|
||||
try:
|
||||
age = time.time() - os.path.getmtime(self._cache_path)
|
||||
if age < self._ttl:
|
||||
self._load_from_cache_file()
|
||||
logger.info(
|
||||
"Loaded %d prices from cache (age %.1fh)",
|
||||
len(self._cache),
|
||||
age / 3600,
|
||||
)
|
||||
return
|
||||
logger.info("Price cache stale (%.1fh old), rebuilding", age / 3600)
|
||||
except Exception as exc:
|
||||
logger.warning("Price cache unreadable, rebuilding: %s", exc)
|
||||
self._rebuild_cache()
|
||||
|
||||
def _load_from_cache_file(self) -> None:
|
||||
"""Deserialize the compact prices cache JSON into memory."""
|
||||
with open(self._cache_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
self._cache = data.get("prices", {})
|
||||
self._last_refresh = data.get("built_at", 0.0)
|
||||
|
||||
def _rebuild_cache(self) -> None:
|
||||
"""Stream the Scryfall bulk data file and extract prices.
|
||||
|
||||
Writes a compact cache JSON then swaps the in-memory dict.
|
||||
Uses an atomic rename so concurrent readers see a complete file.
|
||||
"""
|
||||
if not os.path.exists(self._bulk_path):
|
||||
logger.warning("Scryfall bulk data not found at %s", self._bulk_path)
|
||||
return
|
||||
|
||||
logger.info("Building price cache from %s ...", self._bulk_path)
|
||||
new_cache: Dict[str, Dict[str, float]] = {}
|
||||
built_at = time.time()
|
||||
|
||||
try:
|
||||
with open(self._bulk_path, "r", encoding="utf-8") as fh:
|
||||
for raw_line in fh:
|
||||
line = raw_line.strip().rstrip(",")
|
||||
if not line or line in ("[", "]"):
|
||||
continue
|
||||
try:
|
||||
card = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
name: str = card.get("name", "")
|
||||
prices: Dict[str, Any] = card.get("prices") or {}
|
||||
if not name:
|
||||
continue
|
||||
|
||||
entry = self._extract_prices(prices)
|
||||
if not entry:
|
||||
continue
|
||||
|
||||
# Index by both the combined name and each face name
|
||||
names_to_index = [name]
|
||||
if " // " in name:
|
||||
names_to_index += [part.strip() for part in name.split(" // ")]
|
||||
|
||||
for idx_name in names_to_index:
|
||||
key = idx_name.lower()
|
||||
existing = new_cache.get(key)
|
||||
# Prefer cheapest non-foil USD price across printings
|
||||
new_usd = entry.get("usd", 9999.0)
|
||||
if existing is None or new_usd < existing.get("usd", 9999.0):
|
||||
new_cache[key] = entry
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Failed to parse bulk data: %s", exc)
|
||||
return
|
||||
|
||||
# Write compact cache atomically
|
||||
try:
|
||||
cache_data = {"prices": new_cache, "built_at": built_at}
|
||||
tmp_path = self._cache_path + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(cache_data, fh, separators=(",", ":"))
|
||||
os.replace(tmp_path, self._cache_path)
|
||||
logger.info(
|
||||
"Price cache written: %d cards → %s", len(new_cache), self._cache_path
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write price cache: %s", exc)
|
||||
|
||||
with self._lock:
|
||||
self._cache = new_cache
|
||||
self._last_refresh = built_at
|
||||
# Stamp all keys as fresh so get_stale_cards() reflects the rebuild
|
||||
for key in new_cache:
|
||||
self._lazy_ts[key] = built_at
|
||||
self._save_lazy_ts()
|
||||
|
||||
@staticmethod
|
||||
def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""Convert raw Scryfall prices dict to {region_key: float} entries."""
|
||||
result: Dict[str, float] = {}
|
||||
for key in ("usd", "usd_foil", "eur", "eur_foil"):
|
||||
raw = prices.get(key)
|
||||
if raw is not None and raw != "":
|
||||
try:
|
||||
result[key] = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton (lazy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INSTANCE: Optional[PriceService] = None
|
||||
_INSTANCE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_price_service() -> PriceService:
|
||||
"""Return the shared PriceService singleton, creating it on first call."""
|
||||
global _INSTANCE
|
||||
if _INSTANCE is None:
|
||||
with _INSTANCE_LOCK:
|
||||
if _INSTANCE is None:
|
||||
_INSTANCE = PriceService()
|
||||
return _INSTANCE
|
||||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;"> </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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
79
code/web/templates/build/_budget_review.html
Normal file
79
code/web/templates/build/_budget_review.html
Normal 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)">⏱</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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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">⏱ 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)">⏱</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)">⏱</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"
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
104
code/web/templates/decks/pickups.html
Normal file
104
code/web/templates/decks/pickups.html
Normal 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">⏱ 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)">⏱</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)">⏱</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 %}
|
||||
|
|
@ -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 & 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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue