mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-04-06 05:07:16 +02:00
feat: Card Kingdom prices, shopping cart export, and hover panel fixes (#73)
* feat: add CK prices, shopping cart export, and hover panel fixes * fix: include commander in Buy This Deck cart export
This commit is contained in:
parent
dd996939e6
commit
69d84cc414
24 changed files with 899 additions and 146 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -8,7 +8,28 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
- Link PRs/issues inline when helpful, e.g., (#123) or [#123]. Reference-style links at the bottom are encouraged for readability.
|
- Link PRs/issues inline when helpful, e.g., (#123) or [#123]. Reference-style links at the bottom are encouraged for readability.
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
_No unreleased changes yet._
|
### Added
|
||||||
|
- **Card Kingdom prices**: All price displays now show both TCGPlayer (TCG) and Card Kingdom (CK) prices side by side
|
||||||
|
- Card tile overlays and inline pricing in deck summary, build wizard, and Pickups page
|
||||||
|
- Card hover panel
|
||||||
|
- Upgrade Suggestions table
|
||||||
|
- Alternatives and budget review panels
|
||||||
|
- Card browser grid tiles
|
||||||
|
- Theme detail example cards and commanders
|
||||||
|
- Similar cards panel on card detail pages
|
||||||
|
- Price stat block on individual card detail pages (fetched live via API)
|
||||||
|
- **Price source legend**: "TCG = TCGPlayer · CK = Card Kingdom" label added to the deck summary and Pickups pages for clarity
|
||||||
|
- **Shopping cart export**: One-click deck purchasing via TCGPlayer and Card Kingdom
|
||||||
|
- **Upgrade Suggestions page**: Per-card checkboxes with select-all toggle; "Open in TCGPlayer" and "Open in Card Kingdom" buttons copy the selected card list to the clipboard and open the vendor's mass-entry page in a new tab
|
||||||
|
- **Finished deck view**: "Buy This Deck" toolbar with the same TCGPlayer and Card Kingdom buttons for the complete deck list (commander + all 99 cards)
|
||||||
|
- Clipboard copy shows a confirmation toast; falls back to a copyable text area if clipboard API is unavailable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **"Upgrade Suggestions" rename**: The Pickups page and its button in the deck view are now labelled "Upgrade Suggestions" for clarity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Commander hover panel triggered by entire sidebar**: Hovering any element inside the left-hand card preview column (buttons, text, etc.) incorrectly triggered the commander card hover panel; panel now only activates when hovering the commander image or its direct container
|
||||||
|
- **Commander hover panel missing prices**: Price information was not shown in the commander card hover panel on the finished deck and run-result views; a price overlay is now attached to the commander image so TCG and CK prices load into the hover panel
|
||||||
|
|
||||||
## [4.5.3] - 2026-04-02
|
## [4.5.3] - 2026-04-02
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
# MTG Python Deckbuilder
|
# MTG Python Deckbuilder
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
_No unreleased changes yet._
|
|
||||||
|
|
||||||
## [4.5.3] - 2026-04-02
|
|
||||||
### Added
|
### Added
|
||||||
- **SBOM & supply chain provenance**: Every tagged release now attaches source SBOMs (SPDX + CycloneDX JSON) for Python dependencies and a CycloneDX container image SBOM to the GitHub Release assets. Build provenance attestations (SLSA-style) are published for the multi-arch Docker image via the GitHub Attestations API. `provenance: mode=max` is enabled on all arch builds.
|
- **Card Kingdom prices**: All price displays now show both TCGPlayer (TCG) and Card Kingdom (CK) prices side by side
|
||||||
|
- Card tile overlays and inline pricing in deck summary, build wizard, and Pickups page
|
||||||
|
- Card hover panel
|
||||||
|
- Upgrade Suggestions table
|
||||||
|
- Alternatives and budget review panels
|
||||||
|
- Card browser grid tiles
|
||||||
|
- Theme detail example cards and commanders
|
||||||
|
- Similar cards panel on card detail pages
|
||||||
|
- Price stat block on individual card detail pages (fetched live via API)
|
||||||
|
- **Price source legend**: "TCG = TCGPlayer · CK = Card Kingdom" label added to the deck summary and Pickups pages for clarity
|
||||||
|
- **Shopping cart export**: One-click deck purchasing via TCGPlayer and Card Kingdom
|
||||||
|
- **Upgrade Suggestions page**: Per-card checkboxes with select-all toggle; "Open in TCGPlayer" and "Open in Card Kingdom" buttons copy the selected card list to the clipboard and open the vendor's mass-entry page in a new tab
|
||||||
|
- **Finished deck view**: "Buy This Deck" toolbar with the same TCGPlayer and Card Kingdom buttons for the complete deck list (commander + all 99 cards)
|
||||||
|
- Clipboard copy shows a confirmation toast; falls back to a copyable text area if clipboard API is unavailable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **"Upgrade Suggestions" rename**: The Pickups page and its button in the deck view are now labelled "Upgrade Suggestions" for clarity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Commander hover panel triggered by entire sidebar**: Hovering any element inside the left-hand card preview column (buttons, text, etc.) incorrectly triggered the commander card hover panel; panel now only activates when hovering the commander image or its direct container
|
||||||
|
- **Commander hover panel missing prices**: Price information was not shown in the commander card hover panel on the finished deck and run-result views; a price overlay is now attached to the commander image so TCG and CK prices load into the hover panel
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -897,9 +897,17 @@ class ReportingMixin:
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
"Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness",
|
"Name","Count","Type","ManaCost","ManaValue","Colors","Power","Toughness",
|
||||||
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned","Price"
|
"Role","SubRole","AddedBy","TriggerTag","Synergy","Tags","MetadataTags","Text","DFCNote","Owned","Price (TCGPlayer)"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Auto-inject price service when running in the web context
|
||||||
|
if price_lookup is None:
|
||||||
|
try:
|
||||||
|
from code.web.services.price_service import get_price_service
|
||||||
|
price_lookup = get_price_service().get_prices_batch
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Batch price lookup (no-op when price_lookup not provided)
|
# Batch price lookup (no-op when price_lookup not provided)
|
||||||
card_names_list = list(self.card_library.keys())
|
card_names_list = list(self.card_library.keys())
|
||||||
prices_map: Dict[str, Any] = {}
|
prices_map: Dict[str, Any] = {}
|
||||||
|
|
@ -1068,7 +1076,7 @@ class ReportingMixin:
|
||||||
total_price = sum(
|
total_price = sum(
|
||||||
v for v in prices_map.values() if v is not None
|
v for v in prices_map.values() if v is not None
|
||||||
)
|
)
|
||||||
price_col_index = headers.index('Price')
|
price_col_index = headers.index('Price (TCGPlayer)')
|
||||||
summary_row = [''] * len(headers)
|
summary_row = [''] * len(headers)
|
||||||
summary_row[0] = 'Total'
|
summary_row[0] = 'Total'
|
||||||
summary_row[price_col_index] = f'{total_price:.2f}'
|
summary_row[price_col_index] = f'{total_price:.2f}'
|
||||||
|
|
|
||||||
|
|
@ -414,7 +414,7 @@ def regenerate_processed_parquet() -> None:
|
||||||
|
|
||||||
def refresh_prices_parquet(output_func=None) -> None:
|
def refresh_prices_parquet(output_func=None) -> None:
|
||||||
"""Rebuild the price cache from local Scryfall bulk data and write
|
"""Rebuild the price cache from local Scryfall bulk data and write
|
||||||
``price`` / ``price_updated`` columns into all_cards.parquet and
|
``price`` / ``priceUpdated`` / ``scryfallID`` / ``ckPrice`` / ``ckPriceUpdated`` columns into all_cards.parquet and
|
||||||
commander_cards.parquet.
|
commander_cards.parquet.
|
||||||
|
|
||||||
This is safe to call from both the web app and CLI contexts.
|
This is safe to call from both the web app and CLI contexts.
|
||||||
|
|
@ -455,17 +455,39 @@ def refresh_prices_parquet(output_func=None) -> None:
|
||||||
name_col = "faceName" if "faceName" in df.columns else "name"
|
name_col = "faceName" if "faceName" in df.columns else "name"
|
||||||
card_names = df[name_col].fillna("").tolist()
|
card_names = df[name_col].fillna("").tolist()
|
||||||
|
|
||||||
_log(f"Fetching prices for {len(card_names):,} cards …")
|
# --- scryfallID column (from the map populated during _rebuild_cache) ---
|
||||||
|
scryfall_id_map = getattr(svc, "_scryfall_id_map", {})
|
||||||
|
if scryfall_id_map:
|
||||||
|
df["scryfallID"] = df[name_col].map(lambda n: scryfall_id_map.get(n.lower()) if n else None)
|
||||||
|
mapped = df["scryfallID"].notna().sum()
|
||||||
|
_log(f"Added scryfallID column — {mapped:,} of {len(card_names):,} cards mapped.")
|
||||||
|
else:
|
||||||
|
_log("Warning: scryfallID map empty; skipping scryfallID column.")
|
||||||
|
|
||||||
|
# --- TCGPlayer (Scryfall) prices ---
|
||||||
|
_log(f"Fetching TCGPlayer prices for {len(card_names):,} cards …")
|
||||||
prices = svc.get_prices_batch(card_names)
|
prices = svc.get_prices_batch(card_names)
|
||||||
priced = sum(1 for p in prices.values() if p is not None)
|
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")
|
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"] = df[name_col].map(lambda n: prices.get(n) if n else None)
|
||||||
df["price_updated"] = now_iso
|
df["priceUpdated"] = now_iso
|
||||||
|
|
||||||
|
# --- Card Kingdom prices ---
|
||||||
|
_log("Fetching Card Kingdom price list …")
|
||||||
|
try:
|
||||||
|
svc._rebuild_ck_cache()
|
||||||
|
ck_prices = svc.get_ck_prices_batch(card_names)
|
||||||
|
ck_priced = sum(1 for p in ck_prices.values() if p is not None)
|
||||||
|
df["ckPrice"] = df[name_col].map(lambda n: ck_prices.get(n) if n else None)
|
||||||
|
df["ckPriceUpdated"] = now_iso
|
||||||
|
_log(f"Added ckPrice column — {ck_priced:,} of {len(card_names):,} cards priced.")
|
||||||
|
except Exception as exc:
|
||||||
|
_log(f"Warning: CK price fetch failed ({exc}). Skipping ckPrice column.")
|
||||||
|
|
||||||
loader = DataLoader()
|
loader = DataLoader()
|
||||||
loader.write_cards(df, processed_path)
|
loader.write_cards(df, processed_path)
|
||||||
_log(f"Updated all_cards.parquet — {priced:,} of {len(card_names):,} cards priced.")
|
_log(f"Updated all_cards.parquet — {priced:,} of {len(card_names):,} cards priced (TCGPlayer).")
|
||||||
|
|
||||||
# Update commander_cards.parquet by applying the same price columns.
|
# Update commander_cards.parquet by applying the same price columns.
|
||||||
processed_dir = os.path.dirname(processed_path)
|
processed_dir = os.path.dirname(processed_path)
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,17 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
|
||||||
get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s)
|
get_similarity() # Pre-initialize singleton (one-time cost: ~2-3s)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Warm up price cache in background so first deck view request is fast
|
||||||
|
try:
|
||||||
|
import threading as _threading
|
||||||
|
from .services.price_service import get_price_service as _get_ps
|
||||||
|
_ps = _get_ps()
|
||||||
|
_t = _threading.Thread(target=_ps._ensure_loaded, daemon=True, name="price-cache-warmup")
|
||||||
|
_t.start()
|
||||||
|
_t2 = _threading.Thread(target=_ps._ensure_ck_loaded, daemon=True, name="ck-price-cache-warmup")
|
||||||
|
_t2.start()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Start price auto-refresh scheduler (optional, 1 AM UTC daily)
|
# Start price auto-refresh scheduler (optional, 1 AM UTC daily)
|
||||||
if PRICE_AUTO_REFRESH:
|
if PRICE_AUTO_REFRESH:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -601,6 +601,13 @@ async def decks_pickups(request: Request, name: str) -> HTMLResponse:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
owned: set[str] = set()
|
||||||
|
try:
|
||||||
|
from ..services.build_utils import owned_set as _owned_set
|
||||||
|
owned = _owned_set()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"decks/pickups.html",
|
"decks/pickups.html",
|
||||||
{
|
{
|
||||||
|
|
@ -612,6 +619,7 @@ async def decks_pickups(request: Request, name: str) -> HTMLResponse:
|
||||||
"error": error_msg,
|
"error": error_msg,
|
||||||
"stale_prices": stale_prices,
|
"stale_prices": stale_prices,
|
||||||
"stale_prices_global": stale_prices_global,
|
"stale_prices_global": stale_prices_global,
|
||||||
|
"owned_names": owned,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,11 @@ async def get_card_price(
|
||||||
name = unquote(card_name).strip()
|
name = unquote(card_name).strip()
|
||||||
svc = get_price_service()
|
svc = get_price_service()
|
||||||
price = svc.get_price(name, region=region, foil=foil)
|
price = svc.get_price(name, region=region, foil=foil)
|
||||||
|
ck_price = svc.get_ck_price(name)
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"card_name": name,
|
"card_name": name,
|
||||||
"price": price,
|
"price": price,
|
||||||
|
"ck_price": ck_price,
|
||||||
"region": region,
|
"region": region,
|
||||||
"foil": foil,
|
"foil": foil,
|
||||||
"found": price is not None,
|
"found": price is not None,
|
||||||
|
|
|
||||||
|
|
@ -468,15 +468,18 @@ class BudgetEvaluatorService(BaseService):
|
||||||
# Batch price lookup for all candidates
|
# Batch price lookup for all candidates
|
||||||
candidate_names = list(candidates.keys())
|
candidate_names = list(candidates.keys())
|
||||||
prices = self._price_svc.get_prices_batch(candidate_names, region=region, foil=foil)
|
prices = self._price_svc.get_prices_batch(candidate_names, region=region, foil=foil)
|
||||||
|
ck_prices = self._price_svc.get_ck_prices_batch(candidate_names)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for name, info in candidates.items():
|
for name, info in candidates.items():
|
||||||
price = prices.get(name)
|
price = prices.get(name)
|
||||||
if price is None or price > max_price:
|
if price is None or price > max_price:
|
||||||
continue
|
continue
|
||||||
|
ck_price = ck_prices.get(name)
|
||||||
results.append({
|
results.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"price": round(price, 2),
|
"price": round(price, 2),
|
||||||
|
"ck_price": round(ck_price, 2) if ck_price is not None else None,
|
||||||
"tags": info["tags"],
|
"tags": info["tags"],
|
||||||
"shared_tags": sorted(info["shared_tags"]),
|
"shared_tags": sorted(info["shared_tags"]),
|
||||||
})
|
})
|
||||||
|
|
@ -540,13 +543,11 @@ class BudgetEvaluatorService(BaseService):
|
||||||
def _get_card_tags(self, card_name: str) -> List[str]:
|
def _get_card_tags(self, card_name: str) -> List[str]:
|
||||||
"""Look up theme tags for a single card from the card index."""
|
"""Look up theme tags for a single card from the card index."""
|
||||||
try:
|
try:
|
||||||
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
|
from code.web.services.card_index import maybe_build_index, lookup_card
|
||||||
maybe_build_index()
|
maybe_build_index()
|
||||||
needle = card_name.lower()
|
entry = lookup_card(card_name)
|
||||||
for cards in _CARD_INDEX.values():
|
if entry:
|
||||||
for c in cards:
|
return list(entry.get("tags", []))
|
||||||
if c.get("name", "").lower() == needle:
|
|
||||||
return list(c.get("tags", []))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return []
|
return []
|
||||||
|
|
@ -554,17 +555,14 @@ class BudgetEvaluatorService(BaseService):
|
||||||
def _get_card_broad_type(self, card_name: str) -> Optional[str]:
|
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')."""
|
"""Return the first matching broad MTG type for a card (e.g. 'Land', 'Creature')."""
|
||||||
try:
|
try:
|
||||||
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
|
from code.web.services.card_index import maybe_build_index, lookup_card
|
||||||
maybe_build_index()
|
maybe_build_index()
|
||||||
needle = card_name.lower()
|
entry = lookup_card(card_name)
|
||||||
for cards in _CARD_INDEX.values():
|
if entry:
|
||||||
for c in cards:
|
type_line = entry.get("type_line", "")
|
||||||
if c.get("name", "").lower() == needle:
|
for broad in _BROAD_TYPES:
|
||||||
type_line = c.get("type_line", "")
|
if broad in type_line:
|
||||||
for broad in _BROAD_TYPES:
|
return broad
|
||||||
if broad in type_line:
|
|
||||||
return broad
|
|
||||||
return None
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
@ -620,16 +618,13 @@ class BudgetEvaluatorService(BaseService):
|
||||||
# Collect all unique tags from the current deck
|
# Collect all unique tags from the current deck
|
||||||
deck_tags: Set[str] = set()
|
deck_tags: Set[str] = set()
|
||||||
try:
|
try:
|
||||||
from code.web.services.card_index import maybe_build_index, _CARD_INDEX
|
from code.web.services.card_index import maybe_build_index, _CARD_INDEX, lookup_card
|
||||||
maybe_build_index()
|
maybe_build_index()
|
||||||
|
|
||||||
for name in decklist:
|
for name in decklist:
|
||||||
needle = name.lower()
|
card_entry = lookup_card(name)
|
||||||
for cards in _CARD_INDEX.values():
|
if card_entry:
|
||||||
for c in cards:
|
deck_tags.update(card_entry.get("tags", []))
|
||||||
if c.get("name", "").lower() == needle:
|
|
||||||
deck_tags.update(c.get("tags", []))
|
|
||||||
break
|
|
||||||
|
|
||||||
if not deck_tags:
|
if not deck_tags:
|
||||||
return []
|
return []
|
||||||
|
|
@ -664,6 +659,7 @@ class BudgetEvaluatorService(BaseService):
|
||||||
top_candidates = sorted(candidates.values(), key=lambda x: x["score"], reverse=True)[:200]
|
top_candidates = sorted(candidates.values(), key=lambda x: x["score"], reverse=True)[:200]
|
||||||
names = [c["name"] for c in top_candidates]
|
names = [c["name"] for c in top_candidates]
|
||||||
prices = self._price_svc.get_prices_batch(names, region=region, foil=foil)
|
prices = self._price_svc.get_prices_batch(names, region=region, foil=foil)
|
||||||
|
ck_prices = self._price_svc.get_ck_prices_batch(names)
|
||||||
|
|
||||||
tier_ceilings = self.calculate_tier_ceilings(budget_remaining)
|
tier_ceilings = self.calculate_tier_ceilings(budget_remaining)
|
||||||
pickups: List[Pickup] = []
|
pickups: List[Pickup] = []
|
||||||
|
|
@ -679,9 +675,11 @@ class BudgetEvaluatorService(BaseService):
|
||||||
tier = "M"
|
tier = "M"
|
||||||
if price <= tier_ceilings["S"]:
|
if price <= tier_ceilings["S"]:
|
||||||
tier = "S"
|
tier = "S"
|
||||||
|
ck_price = ck_prices.get(c["name"])
|
||||||
pickups.append({
|
pickups.append({
|
||||||
"card": c["name"],
|
"card": c["name"],
|
||||||
"price": round(price, 2),
|
"price": round(price, 2),
|
||||||
|
"ck_price": round(ck_price, 2) if ck_price is not None else None,
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
"priority": c["score"],
|
"priority": c["score"],
|
||||||
"tags": c["tags"],
|
"tags": c["tags"],
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ RARITY_COL = "rarity"
|
||||||
|
|
||||||
_CARD_INDEX: Dict[str, List[Dict[str, Any]]] = {}
|
_CARD_INDEX: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
_CARD_INDEX_MTIME: float | None = None
|
_CARD_INDEX_MTIME: float | None = None
|
||||||
|
# Reverse lookup: lowercase card name → card dict (first occurrence per name)
|
||||||
|
_NAME_INDEX: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
_RARITY_NORM = {
|
_RARITY_NORM = {
|
||||||
"mythic rare": "mythic",
|
"mythic rare": "mythic",
|
||||||
|
|
@ -50,7 +52,7 @@ def maybe_build_index() -> None:
|
||||||
|
|
||||||
M4: Loads from all_cards.parquet instead of CSV files.
|
M4: Loads from all_cards.parquet instead of CSV files.
|
||||||
"""
|
"""
|
||||||
global _CARD_INDEX, _CARD_INDEX_MTIME
|
global _CARD_INDEX, _CARD_INDEX_MTIME, _NAME_INDEX
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from path_util import get_processed_cards_path
|
from path_util import get_processed_cards_path
|
||||||
|
|
@ -99,6 +101,14 @@ def maybe_build_index() -> None:
|
||||||
})
|
})
|
||||||
|
|
||||||
_CARD_INDEX = new_index
|
_CARD_INDEX = new_index
|
||||||
|
# Build name → card reverse index for O(1) lookups
|
||||||
|
new_name_index: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for cards in new_index.values():
|
||||||
|
for c in cards:
|
||||||
|
key = c.get("name", "").lower()
|
||||||
|
if key and key not in new_name_index:
|
||||||
|
new_name_index[key] = c
|
||||||
|
_NAME_INDEX = new_name_index
|
||||||
_CARD_INDEX_MTIME = latest
|
_CARD_INDEX_MTIME = latest
|
||||||
except Exception:
|
except Exception:
|
||||||
# Defensive: if anything fails, leave index unchanged
|
# Defensive: if anything fails, leave index unchanged
|
||||||
|
|
@ -107,9 +117,20 @@ def maybe_build_index() -> None:
|
||||||
def get_tag_pool(tag: str) -> List[Dict[str, Any]]:
|
def get_tag_pool(tag: str) -> List[Dict[str, Any]]:
|
||||||
return _CARD_INDEX.get(tag, [])
|
return _CARD_INDEX.get(tag, [])
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_card(name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""O(1) lookup of a card dict by name. Returns None if not found."""
|
||||||
|
return _NAME_INDEX.get(name.lower().strip()) if name else None
|
||||||
|
|
||||||
|
|
||||||
def lookup_commander(name: Optional[str]) -> Optional[Dict[str, Any]]:
|
def lookup_commander(name: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
|
# Fast path via name index
|
||||||
|
result = _NAME_INDEX.get(name.lower().strip())
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
# Fallback: full scan (handles index not yet built)
|
||||||
needle = name.lower().strip()
|
needle = name.lower().strip()
|
||||||
for tag_cards in _CARD_INDEX.values():
|
for tag_cards in _CARD_INDEX.values():
|
||||||
for c in tag_cards:
|
for c in tag_cards:
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ logger.addHandler(logging_util.stream_handler)
|
||||||
_CACHE_TTL_SECONDS = 86400 # 24 hours
|
_CACHE_TTL_SECONDS = 86400 # 24 hours
|
||||||
_BULK_DATA_FILENAME = "scryfall_bulk_data.json"
|
_BULK_DATA_FILENAME = "scryfall_bulk_data.json"
|
||||||
_PRICE_CACHE_FILENAME = "prices_cache.json"
|
_PRICE_CACHE_FILENAME = "prices_cache.json"
|
||||||
|
_CK_CACHE_FILENAME = "ck_prices_cache.json"
|
||||||
|
_CK_API_URL = "https://api.cardkingdom.com/api/v2/pricelist"
|
||||||
|
|
||||||
|
|
||||||
class PriceService(BaseService):
|
class PriceService(BaseService):
|
||||||
|
|
@ -68,6 +70,14 @@ class PriceService(BaseService):
|
||||||
self._miss_count = 0
|
self._miss_count = 0
|
||||||
self._refresh_thread: Optional[threading.Thread] = None
|
self._refresh_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
# CK price cache: {normalized_card_name: float (cheapest non-foil retail)}
|
||||||
|
self._ck_cache_path: str = os.path.join(card_files_dir(), _CK_CACHE_FILENAME)
|
||||||
|
self._ck_cache: Dict[str, float] = {}
|
||||||
|
self._ck_loaded: bool = False
|
||||||
|
|
||||||
|
# scryfall_id map built during _rebuild_cache: {name.lower(): scryfall_id}
|
||||||
|
self._scryfall_id_map: Dict[str, str] = {}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -157,6 +167,35 @@ class PriceService(BaseService):
|
||||||
"bulk_data_available": os.path.exists(self._bulk_path),
|
"bulk_data_available": os.path.exists(self._bulk_path),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CK Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_ck_price(self, card_name: str) -> Optional[float]:
|
||||||
|
"""Return the Card Kingdom retail price for *card_name*, or None."""
|
||||||
|
self._ensure_ck_loaded()
|
||||||
|
return self._ck_cache.get(card_name.lower().strip())
|
||||||
|
|
||||||
|
def get_ck_prices_batch(self, card_names: List[str]) -> Dict[str, Optional[float]]:
|
||||||
|
"""Return a mapping of card name → CK retail price for all requested cards."""
|
||||||
|
self._ensure_ck_loaded()
|
||||||
|
return {
|
||||||
|
name: self._ck_cache.get(name.lower().strip())
|
||||||
|
for name in card_names
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_ck_built_at(self) -> Optional[str]:
|
||||||
|
"""Return a human-readable CK cache build date, or None if unavailable."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self._ck_cache_path):
|
||||||
|
import datetime
|
||||||
|
built = os.path.getmtime(self._ck_cache_path)
|
||||||
|
dt = datetime.datetime.fromtimestamp(built, tz=datetime.timezone.utc)
|
||||||
|
return dt.strftime("%B %d, %Y")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
def refresh_cache_background(self) -> None:
|
def refresh_cache_background(self) -> None:
|
||||||
"""Spawn a daemon thread to rebuild the price cache asynchronously.
|
"""Spawn a daemon thread to rebuild the price cache asynchronously.
|
||||||
|
|
||||||
|
|
@ -412,6 +451,7 @@ class PriceService(BaseService):
|
||||||
|
|
||||||
logger.info("Building price cache from %s ...", self._bulk_path)
|
logger.info("Building price cache from %s ...", self._bulk_path)
|
||||||
new_cache: Dict[str, Dict[str, float]] = {}
|
new_cache: Dict[str, Dict[str, float]] = {}
|
||||||
|
new_scryfall_id_map: Dict[str, str] = {}
|
||||||
built_at = time.time()
|
built_at = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -426,6 +466,7 @@ class PriceService(BaseService):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
name: str = card.get("name", "")
|
name: str = card.get("name", "")
|
||||||
|
scryfall_id: str = card.get("id", "")
|
||||||
prices: Dict[str, Any] = card.get("prices") or {}
|
prices: Dict[str, Any] = card.get("prices") or {}
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
|
|
@ -446,6 +487,9 @@ class PriceService(BaseService):
|
||||||
new_usd = entry.get("usd", 9999.0)
|
new_usd = entry.get("usd", 9999.0)
|
||||||
if existing is None or new_usd < existing.get("usd", 9999.0):
|
if existing is None or new_usd < existing.get("usd", 9999.0):
|
||||||
new_cache[key] = entry
|
new_cache[key] = entry
|
||||||
|
# Track the scryfall_id of the cheapest-priced printing
|
||||||
|
if scryfall_id:
|
||||||
|
new_scryfall_id_map[key] = scryfall_id
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to parse bulk data: %s", exc)
|
logger.error("Failed to parse bulk data: %s", exc)
|
||||||
|
|
@ -466,6 +510,7 @@ class PriceService(BaseService):
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._cache = new_cache
|
self._cache = new_cache
|
||||||
|
self._scryfall_id_map = new_scryfall_id_map
|
||||||
self._last_refresh = built_at
|
self._last_refresh = built_at
|
||||||
# Stamp all keys as fresh so get_stale_cards() reflects the rebuild.
|
# Stamp all keys as fresh so get_stale_cards() reflects the rebuild.
|
||||||
# _lazy_ts may not exist if start_lazy_refresh() was never called
|
# _lazy_ts may not exist if start_lazy_refresh() was never called
|
||||||
|
|
@ -476,9 +521,102 @@ class PriceService(BaseService):
|
||||||
self._lazy_ts[key] = built_at
|
self._lazy_ts[key] = built_at
|
||||||
self._save_lazy_ts()
|
self._save_lazy_ts()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CK internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ensure_ck_loaded(self) -> None:
|
||||||
|
"""Lazy-load the CK price cache on first access (double-checked lock)."""
|
||||||
|
if self._ck_loaded:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if self._ck_loaded:
|
||||||
|
return
|
||||||
|
if os.path.exists(self._ck_cache_path):
|
||||||
|
try:
|
||||||
|
age = time.time() - os.path.getmtime(self._ck_cache_path)
|
||||||
|
if age < self._ttl:
|
||||||
|
self._load_ck_from_cache()
|
||||||
|
logger.info("Loaded %d CK prices from cache (age %.1fh)", len(self._ck_cache), age / 3600)
|
||||||
|
self._ck_loaded = True
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("CK cache unreadable: %s", exc)
|
||||||
|
# No fresh cache — set loaded flag anyway so we degrade gracefully
|
||||||
|
# rather than blocking every request. CK rebuild happens via Setup page.
|
||||||
|
self._ck_loaded = True
|
||||||
|
|
||||||
|
def _rebuild_ck_cache(self) -> None:
|
||||||
|
"""Fetch the Card Kingdom price list and cache retail prices by card name.
|
||||||
|
|
||||||
|
Fetches https://api.cardkingdom.com/api/v2/pricelist, takes the cheapest
|
||||||
|
non-foil price_retail per card name (across all printings), and writes
|
||||||
|
ck_prices_cache.json atomically.
|
||||||
|
"""
|
||||||
|
import urllib.request as _urllib
|
||||||
|
logger.info("Fetching CK price list from %s ...", _CK_API_URL)
|
||||||
|
try:
|
||||||
|
req = _urllib.Request(_CK_API_URL, headers={"User-Agent": "MTGPythonDeckbuilder/1.0"})
|
||||||
|
with _urllib.urlopen(req, timeout=30) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("CK price fetch failed: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
items = data.get("data", [])
|
||||||
|
meta_created_at = data.get("meta", {}).get("created_at", "")
|
||||||
|
new_ck: Dict[str, float] = {}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item.get("is_foil") == "true":
|
||||||
|
continue
|
||||||
|
name = item.get("name", "")
|
||||||
|
price_str = item.get("price_retail", "")
|
||||||
|
if not name or not price_str:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price = float(price_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if price <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Index by full name and each face for split/DFC cards
|
||||||
|
keys_to_index = [name.lower()]
|
||||||
|
if " // " in name:
|
||||||
|
keys_to_index += [part.strip().lower() for part in name.split(" // ")]
|
||||||
|
|
||||||
|
for key in keys_to_index:
|
||||||
|
if key not in new_ck or price < new_ck[key]:
|
||||||
|
new_ck[key] = price
|
||||||
|
|
||||||
|
# Write cache atomically
|
||||||
|
try:
|
||||||
|
cache_data = {"ck_prices": new_ck, "built_at": time.time(), "meta_created_at": meta_created_at}
|
||||||
|
tmp = self._ck_cache_path + ".tmp"
|
||||||
|
os.makedirs(os.path.dirname(self._ck_cache_path), exist_ok=True)
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(cache_data, fh, separators=(",", ":"))
|
||||||
|
os.replace(tmp, self._ck_cache_path)
|
||||||
|
logger.info("CK price cache written: %d cards → %s", len(new_ck), self._ck_cache_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to write CK price cache: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._ck_cache = new_ck
|
||||||
|
self._ck_loaded = True
|
||||||
|
|
||||||
|
def _load_ck_from_cache(self) -> None:
|
||||||
|
"""Deserialize the CK prices cache JSON into memory."""
|
||||||
|
with open(self._ck_cache_path, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
self._ck_cache = data.get("ck_prices", {})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]:
|
def _extract_prices(prices: Dict[str, Any]) -> Dict[str, float]:
|
||||||
"""Convert raw Scryfall prices dict to {region_key: float} entries."""
|
"""Convert raw Scryfall prices dict to {region_key: float} entries."""
|
||||||
|
|
||||||
result: Dict[str, float] = {}
|
result: Dict[str, float] = {}
|
||||||
for key in ("usd", "usd_foil", "eur", "eur_foil"):
|
for key in ("usd", "usd_foil", "eur", "eur_foil"):
|
||||||
raw = prices.get(key)
|
raw = prices.get(key)
|
||||||
|
|
|
||||||
|
|
@ -5993,6 +5993,45 @@ footer.site-footer {
|
||||||
color: var(--muted, #b6b8bd);
|
color: var(--muted, #b6b8bd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.owned-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .1rem .45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ok, #16a34a);
|
||||||
|
border: 1px solid var(--ok, #16a34a);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dual-price source labels (TCG / CK) */
|
||||||
|
|
||||||
|
.price-src-label {
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: .65;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-sep {
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-prices {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: .7;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Price source legend (TCG = TCGPlayer · CK = Card Kingdom) */
|
||||||
|
|
||||||
|
.price-legend {
|
||||||
|
font-size: .7rem;
|
||||||
|
opacity: .55;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Inline price tooltip on card names */
|
/* Inline price tooltip on card names */
|
||||||
|
|
||||||
.card-name-price-hover {
|
.card-name-price-hover {
|
||||||
|
|
@ -6382,6 +6421,104 @@ footer.site-footer {
|
||||||
font-size: .92rem;
|
font-size: .92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shopping cart toolbar — pickups page */
|
||||||
|
|
||||||
|
.cart-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
padding: .45rem .6rem;
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-toolbar .cart-label {
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-copy-feedback {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #34d399;
|
||||||
|
margin-left: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-copy-feedback.error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-fallback-wrap {
|
||||||
|
margin-top: .6rem;
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-fallback-wrap p {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
margin: 0 0 .4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-fallback-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 110px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
padding: .4rem .5rem;
|
||||||
|
background: var(--bg, #0f172a);
|
||||||
|
color: var(--text, #e5e7eb);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-cb-th {
|
||||||
|
width: 2.2rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: .4rem .25rem;
|
||||||
|
border-bottom: 1px solid var(--border, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-cb-td {
|
||||||
|
text-align: center;
|
||||||
|
padding: .35rem .25rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle, #222);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-center toast for buy/cart copy confirmation */
|
||||||
|
|
||||||
|
.cart-toast-top {
|
||||||
|
position: fixed;
|
||||||
|
top: 1.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
color: var(--text, #e5e7eb);
|
||||||
|
border: 1px solid #34d399;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem 1.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,.45);
|
||||||
|
z-index: 10000;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .25s ease, transform .25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-toast-top.hide {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-6px);
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:text-gray-700:hover {
|
.hover\:text-gray-700:hover {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
|
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
|
||||||
|
|
@ -6422,3 +6559,4 @@ footer.site-footer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3728,6 +3728,43 @@ footer.site-footer {
|
||||||
color: var(--muted, #b6b8bd);
|
color: var(--muted, #b6b8bd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.owned-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .1rem .45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ok, #16a34a);
|
||||||
|
border: 1px solid var(--ok, #16a34a);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dual-price source labels (TCG / CK) */
|
||||||
|
.price-src-label {
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: .65;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-sep {
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-prices {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: .7;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Price source legend (TCG = TCGPlayer · CK = Card Kingdom) */
|
||||||
|
.price-legend {
|
||||||
|
font-size: .7rem;
|
||||||
|
opacity: .55;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Inline price tooltip on card names */
|
/* Inline price tooltip on card names */
|
||||||
.card-name-price-hover {
|
.card-name-price-hover {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
@ -3957,3 +3994,86 @@ footer.site-footer {
|
||||||
font-size: .92rem;
|
font-size: .92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shopping cart toolbar — pickups page */
|
||||||
|
.cart-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
padding: .45rem .6rem;
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cart-toolbar .cart-label {
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cart-copy-feedback {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #34d399;
|
||||||
|
margin-left: .25rem;
|
||||||
|
}
|
||||||
|
.cart-copy-feedback.error { color: #f87171; }
|
||||||
|
.cart-fallback-wrap {
|
||||||
|
margin-top: .6rem;
|
||||||
|
padding: .5rem .6rem;
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cart-fallback-wrap p {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted, #94a3b8);
|
||||||
|
margin: 0 0 .4rem 0;
|
||||||
|
}
|
||||||
|
.cart-fallback-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 110px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: .8rem;
|
||||||
|
padding: .4rem .5rem;
|
||||||
|
background: var(--bg, #0f172a);
|
||||||
|
color: var(--text, #e5e7eb);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cart-cb-th {
|
||||||
|
width: 2.2rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: .4rem .25rem;
|
||||||
|
border-bottom: 1px solid var(--border, #333);
|
||||||
|
}
|
||||||
|
.cart-cb-td {
|
||||||
|
text-align: center;
|
||||||
|
padding: .35rem .25rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle, #222);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top-center toast for buy/cart copy confirmation */
|
||||||
|
.cart-toast-top {
|
||||||
|
position: fixed;
|
||||||
|
top: 1.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--panel, #1a1f2e);
|
||||||
|
color: var(--text, #e5e7eb);
|
||||||
|
border: 1px solid #34d399;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem 1.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,.45);
|
||||||
|
z-index: 10000;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity .25s ease, transform .25s ease;
|
||||||
|
}
|
||||||
|
.cart-toast-top.hide { opacity: 0; transform: translateX(-50%) translateY(-6px); }
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,12 +396,24 @@ interface PointerEventLike {
|
||||||
nameEl.textContent = nm;
|
nameEl.textContent = nm;
|
||||||
rarityEl.textContent = rarity;
|
rarityEl.textContent = rarity;
|
||||||
if (priceEl) {
|
if (priceEl) {
|
||||||
const priceRaw = (attr('data-price') || '').trim();
|
|
||||||
const priceNum = priceRaw ? parseFloat(priceRaw) : NaN;
|
|
||||||
const isStale = attr('data-stale') === '1';
|
const isStale = attr('data-stale') === '1';
|
||||||
priceEl.innerHTML = !isNaN(priceNum)
|
const staleSpan = isStale ? ' <span style="color:#f59e0b;font-size:10px;" title="Price may be outdated (>24h)">\u23F1</span>' : '';
|
||||||
? '$' + priceNum.toFixed(2) + (isStale ? ' <span style="color:#f59e0b;font-size:10px;" title="Price may be outdated (>24h)">\u23F1</span>' : '')
|
// Prefer the shared price cache (populated by initPriceDisplay with both TCG+CK)
|
||||||
: '';
|
const globalCache = (window as any)._priceNum as Record<string, {tcg: number|null, ck: number|null}|null> | undefined;
|
||||||
|
const cached = globalCache && globalCache[nm];
|
||||||
|
if (cached && (cached.tcg !== null || cached.ck !== null)) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (cached.tcg !== null) parts.push('<span class="price-src-label">TCG</span> $' + (cached.tcg as number).toFixed(2) + staleSpan);
|
||||||
|
if (cached.ck !== null) parts.push('<span class="price-src-label">CK</span> $' + (cached.ck as number).toFixed(2));
|
||||||
|
priceEl.innerHTML = parts.join(' <span class="price-sep">\u00B7</span> ');
|
||||||
|
} else {
|
||||||
|
// Fallback: data-price (TCG only, set server-side in _step5.html)
|
||||||
|
const priceRaw = (attr('data-price') || '').trim();
|
||||||
|
const priceNum = priceRaw ? parseFloat(priceRaw) : NaN;
|
||||||
|
priceEl.innerHTML = !isNaN(priceNum)
|
||||||
|
? '<span class="price-src-label">TCG</span> $' + priceNum.toFixed(2) + staleSpan
|
||||||
|
: '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleLabel = displayLabel(role);
|
const roleLabel = displayLabel(role);
|
||||||
|
|
@ -680,7 +692,12 @@ interface PointerEventLike {
|
||||||
|
|
||||||
// Recognized container classes
|
// Recognized container classes
|
||||||
const container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card');
|
const container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card');
|
||||||
if (container) return container;
|
if (container) {
|
||||||
|
// .card-preview is also used as a plain layout aside (no data-card-name on it);
|
||||||
|
// only treat it as a hover target when the attribute lives directly on the element.
|
||||||
|
if (container.classList.contains('card-preview') && !container.hasAttribute('data-card-name')) return null;
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
// Image-based detection (any card image carrying data-card-name)
|
// Image-based detection (any card image carrying data-card-name)
|
||||||
if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))) {
|
if (el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))) {
|
||||||
|
|
|
||||||
|
|
@ -434,7 +434,10 @@
|
||||||
fetch('/api/price/' + encodeURIComponent(name))
|
fetch('/api/price/' + encodeURIComponent(name))
|
||||||
.then(function(r){ return r.ok ? r.json() : null; })
|
.then(function(r){ return r.ok ? r.json() : null; })
|
||||||
.then(function(d){
|
.then(function(d){
|
||||||
var label = (d && d.found && d.price != null) ? ('$' + parseFloat(d.price).toFixed(2)) : 'Price unavailable';
|
var parts = [];
|
||||||
|
if (d && d.found && d.price != null) parts.push('TCG: $' + parseFloat(d.price).toFixed(2));
|
||||||
|
if (d && d.ck_price != null) parts.push('CK: $' + parseFloat(d.ck_price).toFixed(2));
|
||||||
|
var label = parts.length ? parts.join(' ') : 'Price unavailable';
|
||||||
_priceCache[name] = label;
|
_priceCache[name] = label;
|
||||||
_showTip(el, label);
|
_showTip(el, label);
|
||||||
})
|
})
|
||||||
|
|
@ -454,7 +457,8 @@
|
||||||
'Snow-Covered Plains','Snow-Covered Island','Snow-Covered Swamp',
|
'Snow-Covered Plains','Snow-Covered Island','Snow-Covered Swamp',
|
||||||
'Snow-Covered Mountain','Snow-Covered Forest'
|
'Snow-Covered Mountain','Snow-Covered Forest'
|
||||||
]);
|
]);
|
||||||
var _priceNum = {}; // card name -> float|null
|
var _priceNum = {}; // card name -> {tcg, ck}|null
|
||||||
|
window._priceNum = _priceNum; // expose for cardHover.js
|
||||||
var _deckPrices = {}; // accumulated across build stages: card name -> float
|
var _deckPrices = {}; // accumulated across build stages: card name -> float
|
||||||
var _buildToken = null;
|
var _buildToken = null;
|
||||||
function _fetchNum(name) {
|
function _fetchNum(name) {
|
||||||
|
|
@ -462,9 +466,12 @@
|
||||||
return fetch('/api/price/' + encodeURIComponent(name))
|
return fetch('/api/price/' + encodeURIComponent(name))
|
||||||
.then(function(r){ return r.ok ? r.json() : null; })
|
.then(function(r){ return r.ok ? r.json() : null; })
|
||||||
.then(function(d){
|
.then(function(d){
|
||||||
var p = (d && d.found && d.price != null) ? parseFloat(d.price) : null;
|
var obj = {
|
||||||
_priceNum[name] = p; return p;
|
tcg: (d && d.found && d.price != null) ? parseFloat(d.price) : null,
|
||||||
}).catch(function(){ _priceNum[name] = null; return null; });
|
ck: (d && d.ck_price != null) ? parseFloat(d.ck_price) : null,
|
||||||
|
};
|
||||||
|
_priceNum[name] = obj; return obj;
|
||||||
|
}).catch(function(){ var obj = {tcg:null,ck:null}; _priceNum[name] = obj; return obj; });
|
||||||
}
|
}
|
||||||
function _getBuildToken() {
|
function _getBuildToken() {
|
||||||
var el = document.querySelector('[data-build-id]');
|
var el = document.querySelector('[data-build-id]');
|
||||||
|
|
@ -507,17 +514,24 @@
|
||||||
|
|
||||||
var prevTotal = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
var prevTotal = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
||||||
var promises = [];
|
var promises = [];
|
||||||
toFetch.forEach(function(name){ promises.push(_fetchNum(name).then(function(p){ return {name:name,price:p}; })); });
|
toFetch.forEach(function(name){ promises.push(_fetchNum(name).then(function(obj){ return {name:name,price:obj}; })); });
|
||||||
Promise.all(promises).then(function(results){
|
Promise.all(promises).then(function(results){
|
||||||
var map = {};
|
var map = {};
|
||||||
var prevTotal2 = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
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; });
|
results.forEach(function(r){ map[r.name] = r.price; if (r.price && r.price.tcg !== null) _deckPrices[r.name] = r.price.tcg; });
|
||||||
overlays.forEach(function(el){
|
overlays.forEach(function(el){
|
||||||
var name = el.dataset.priceFor;
|
var name = el.dataset.priceFor;
|
||||||
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
||||||
var p = map[name];
|
var obj = map[name];
|
||||||
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
|
var tcg = obj ? obj.tcg : null;
|
||||||
if (ceiling !== null && p !== null && p > ceiling) {
|
var ck = obj ? obj.ck : null;
|
||||||
|
if (tcg !== null || ck !== null) {
|
||||||
|
var parts = [];
|
||||||
|
if (tcg !== null) parts.push('<span class="price-src-label">TCG</span> $' + tcg.toFixed(2));
|
||||||
|
if (ck !== null) parts.push('<span class="price-src-label">CK</span> $' + ck.toFixed(2));
|
||||||
|
el.innerHTML = parts.join('<br>');
|
||||||
|
} else { el.innerHTML = ''; }
|
||||||
|
if (ceiling !== null && tcg !== null && tcg > ceiling) {
|
||||||
var tile = el.closest('.card-tile,.stack-card');
|
var tile = el.closest('.card-tile,.stack-card');
|
||||||
if (tile) tile.classList.add('over-budget');
|
if (tile) tile.classList.add('over-budget');
|
||||||
}
|
}
|
||||||
|
|
@ -525,9 +539,16 @@
|
||||||
inlines.forEach(function(el){
|
inlines.forEach(function(el){
|
||||||
var name = el.dataset.priceFor;
|
var name = el.dataset.priceFor;
|
||||||
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
||||||
var p = map[name];
|
var obj = map[name];
|
||||||
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
|
var tcg = obj ? obj.tcg : null;
|
||||||
if (ceiling !== null && p !== null && p > ceiling) {
|
var ck = obj ? obj.ck : null;
|
||||||
|
if (tcg !== null || ck !== null) {
|
||||||
|
var parts = [];
|
||||||
|
if (tcg !== null) parts.push('<span class="price-src-label">TCG</span> $' + tcg.toFixed(2));
|
||||||
|
if (ck !== null) parts.push('<span class="price-src-label">CK</span> $' + ck.toFixed(2));
|
||||||
|
el.innerHTML = parts.join(' <span class="price-sep">·</span> ');
|
||||||
|
} else { el.innerHTML = ''; }
|
||||||
|
if (ceiling !== null && tcg !== null && tcg > ceiling) {
|
||||||
var row = el.closest('.list-row');
|
var row = el.closest('.list-row');
|
||||||
if (row) row.classList.add('over-budget');
|
if (row) row.classList.add('over-budget');
|
||||||
}
|
}
|
||||||
|
|
@ -543,7 +564,7 @@
|
||||||
var n = el.dataset.priceFor;
|
var n = el.dataset.priceFor;
|
||||||
if (n && !BASIC_LANDS.has(n) && !allNames.has(n)) {
|
if (n && !BASIC_LANDS.has(n) && !allNames.has(n)) {
|
||||||
allNames.add(n);
|
allNames.add(n);
|
||||||
sumTotal += (map[n] || 0);
|
sumTotal += (map[n] ? (map[n].tcg || 0) : 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (totalCap !== null) {
|
if (totalCap !== null) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@
|
||||||
{{ card.name }}
|
{{ card.name }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Price overlay #}
|
||||||
|
<div class="card-price-overlay" data-price-for="{{ card.name }}" aria-hidden="true"></div>
|
||||||
|
|
||||||
{# Owned indicator #}
|
{# Owned indicator #}
|
||||||
{% if card.is_owned %}
|
{% if card.is_owned %}
|
||||||
<div style="position:absolute; top:4px; right:4px; background:rgba(34,197,94,0.9); color:white; padding:2px 6px; border-radius:4px; font-size:12px; font-weight:600;">
|
<div style="position:absolute; top:4px; right:4px; background:rgba(34,197,94,0.9); color:white; padding:2px 6px; border-radius:4px; font-size:12px; font-weight:600;">
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.similar-card-image:hover {
|
.similar-card-image:hover {
|
||||||
|
|
@ -235,6 +236,8 @@
|
||||||
<div class="similar-card-tile card-tile" data-card-name="{{ card.name }}">
|
<div class="similar-card-tile card-tile" data-card-name="{{ card.name }}">
|
||||||
<!-- Card Image (uses hover system for preview) -->
|
<!-- Card Image (uses hover system for preview) -->
|
||||||
<div class="similar-card-image">
|
<div class="similar-card-image">
|
||||||
|
{# Price overlay #}
|
||||||
|
<div class="card-price-overlay" data-price-for="{{ card.name }}" aria-hidden="true"></div>
|
||||||
<img src="{{ card.name|card_image('normal') }}"
|
<img src="{{ card.name|card_image('normal') }}"
|
||||||
alt="{{ card.name }}"
|
alt="{{ card.name }}"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,29 @@
|
||||||
<span class="card-stat-value">{{ card.rarity | capitalize }}</span>
|
<span class="card-stat-value">{{ card.rarity | capitalize }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-stat" id="card-detail-price-wrap" style="display:none;">
|
||||||
|
<span class="card-stat-label">Price</span>
|
||||||
|
<span class="card-stat-value" id="card-detail-price-display"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var name = {{ card.name | tojson }};
|
||||||
|
fetch('/api/price/' + encodeURIComponent(name))
|
||||||
|
.then(function(r){ return r.ok ? r.json() : null; })
|
||||||
|
.then(function(d){
|
||||||
|
if (!d) return;
|
||||||
|
var parts = [];
|
||||||
|
if (d.price != null) parts.push('<span class="price-src-label">TCG</span> $' + parseFloat(d.price).toFixed(2));
|
||||||
|
if (d.ck_price != null) parts.push('<span class="price-src-label">CK</span> $' + parseFloat(d.ck_price).toFixed(2));
|
||||||
|
if (parts.length) {
|
||||||
|
document.getElementById('card-detail-price-display').innerHTML = parts.join(' <span class="price-sep">\u00B7</span> ');
|
||||||
|
document.getElementById('card-detail-price-wrap').style.display = '';
|
||||||
|
}
|
||||||
|
}).catch(function(){});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Oracle Text -->
|
<!-- Oracle Text -->
|
||||||
{% if card.text %}
|
{% if card.text %}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
hx-target="closest .alts"
|
hx-target="closest .alts"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
title="Lock this alternative and unlock the current pick">
|
title="Lock this alternative and unlock the current pick">
|
||||||
{{ it.name }}{% if it.price %} <span style="font-size:11px;opacity:.7;font-weight:normal;">${{ "%.2f"|format(it.price|float) }}</span>{% endif %}
|
{{ it.name }}{% if it.price or it.ck_price %} <span class="alt-prices">{% if it.price %}<span class="price-src-label">TCG</span> ${{ "%.2f"|format(it.price|float) }}{% endif %}{% if it.price and it.ck_price %} · {% endif %}{% if it.ck_price %}<span class="price-src-label">CK</span> ${{ "%.2f"|format(it.ck_price|float) }}{% endif %}</span>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
{% if alt.price %}data-price="{{ alt.price }}"{% endif %}
|
{% if alt.price %}data-price="{{ alt.price }}"{% endif %}
|
||||||
title="{{ alt.shared_tags|join(', ') if alt.shared_tags else '' }}">
|
title="{{ alt.shared_tags|join(', ') if alt.shared_tags else '' }}">
|
||||||
{{ alt.name }}
|
{{ alt.name }}
|
||||||
{% if alt.price %}<span class="alt-price">${{ '%.2f'|format(alt.price|float) }}</span>{% endif %}
|
{% if alt.price or alt.ck_price %}<span class="alt-price">{% if alt.price %}TCG ${{ '%.2f'|format(alt.price|float) }}{% endif %}{% if alt.price and alt.ck_price %} · {% endif %}{% if alt.ck_price %}CK ${{ '%.2f'|format(alt.ck_price|float) }}{% endif %}</span>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block banner_subtitle %}Budget Pickups{% endblock %}
|
{% block banner_subtitle %}Upgrade Suggestions{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Pickups List</h2>
|
<h2>Upgrade Suggestions</h2>
|
||||||
{% if commander %}
|
{% if commander %}
|
||||||
<div class="muted" style="margin-bottom:.5rem;">Deck: <strong>{{ commander }}</strong>{% if name %} — <span class="muted text-xs">{{ name }}</span>{% endif %}</div>
|
<div class="muted" style="margin-bottom:.5rem;">Deck: <strong>{{ commander }}</strong>{% if name %} — <span class="muted text-xs">{{ name }}</span>{% endif %}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -30,13 +30,35 @@
|
||||||
|
|
||||||
{% if budget_report.pickups_list %}
|
{% if budget_report.pickups_list %}
|
||||||
<p class="muted text-sm" style="margin-bottom:.5rem;">
|
<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.
|
Cards that fit the deck's themes and budget. Owned cards are free upgrades — just swap one in. Sorted by theme match priority.
|
||||||
|
<span class="price-legend" style="display:block; margin-top:.2rem;">TCG = TCGPlayer · CK = Card Kingdom</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{# Cart toolbar #}
|
||||||
|
<div class="cart-toolbar" id="cart-toolbar">
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.85rem;">
|
||||||
|
<input type="checkbox" id="cart-select-all" checked aria-label="Select all cards">
|
||||||
|
<span>Select all</span>
|
||||||
|
</label>
|
||||||
|
<span id="cart-selected-count" class="cart-label"></span>
|
||||||
|
<button id="btn-copy-tcg" class="btn btn-sm" type="button" onclick="cartCopyTCG()">Open in TCGPlayer</button>
|
||||||
|
<button id="btn-copy-ck" class="btn btn-sm" type="button" onclick="cartCopyCK()">Open in Card Kingdom</button>
|
||||||
|
</div>
|
||||||
|
{# Fallback textarea (shown when Clipboard API is unavailable) #}
|
||||||
|
<div id="cart-fallback-wrap" class="cart-fallback-wrap" style="display:none;">
|
||||||
|
<p>Clipboard access unavailable. Select all and copy the text below:</p>
|
||||||
|
<textarea id="cart-fallback-text" class="cart-fallback-textarea" readonly aria-label="Card list for manual copy"></textarea>
|
||||||
|
<button type="button" class="btn btn-sm" style="margin-top:.4rem;" onclick="document.getElementById('cart-fallback-wrap').style.display='none';">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="pickups-table" style="width:100%; border-collapse:collapse;">
|
<table class="pickups-table" style="width:100%; border-collapse:collapse;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="cart-cb-th" aria-label="Select"></th>
|
||||||
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Card</th>
|
<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:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">TCG</th>
|
||||||
|
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">CK</th>
|
||||||
|
<th style="text-align:center; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Owned</th>
|
||||||
<th style="text-align:center; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Tier</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>
|
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Priority</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -44,16 +66,33 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for card in budget_report.pickups_list %}
|
{% for card in budget_report.pickups_list %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="cart-cb-td">
|
||||||
|
<input type="checkbox" class="cart-cb" data-card-name="{{ card.card|e }}" checked aria-label="Select {{ card.card|e }}">
|
||||||
|
</td>
|
||||||
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
<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>
|
<span class="card-name-price-hover" data-card-name="{{ card.card|e }}">{{ card.card }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||||
{% if card.price is not none %}
|
{% if owned_names is defined and card.card|lower in owned_names %}
|
||||||
|
<span class="muted text-sm">owned</span>
|
||||||
|
{% elif 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 %}
|
${{ "%.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 %}
|
{% else %}
|
||||||
<span class="muted">–</span>
|
<span class="muted">–</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||||
|
{% if card.ck_price is not none %}
|
||||||
|
${{ "%.2f"|format(card.ck_price) }}
|
||||||
|
{% else %}
|
||||||
|
<span class="muted">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||||
|
{% if owned_names is defined and card.card|lower in owned_names %}
|
||||||
|
<span class="owned-badge" title="You own this card">yes</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td style="text-align:center; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
<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>
|
<span class="tier-badge tier-badge--{{ card.tier|lower }}">{{ card.tier }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -64,8 +103,84 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var allCb = document.getElementById('cart-select-all');
|
||||||
|
|
||||||
|
function getCheckedNames() {
|
||||||
|
return Array.from(document.querySelectorAll('.cart-cb:checked'))
|
||||||
|
.map(function (cb) { return cb.getAttribute('data-card-name'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateState() {
|
||||||
|
var all = document.querySelectorAll('.cart-cb');
|
||||||
|
var checked = document.querySelectorAll('.cart-cb:checked');
|
||||||
|
var n = checked.length, total = all.length;
|
||||||
|
var countEl = document.getElementById('cart-selected-count');
|
||||||
|
if (countEl) countEl.textContent = n + ' of ' + total + ' selected';
|
||||||
|
var btnTCG = document.getElementById('btn-copy-tcg');
|
||||||
|
var btnCK = document.getElementById('btn-copy-ck');
|
||||||
|
if (btnTCG) btnTCG.disabled = n === 0;
|
||||||
|
if (btnCK) btnCK.disabled = n === 0;
|
||||||
|
if (allCb) {
|
||||||
|
allCb.indeterminate = n > 0 && n < total;
|
||||||
|
allCb.checked = n === total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allCb) {
|
||||||
|
allCb.addEventListener('change', function () {
|
||||||
|
document.querySelectorAll('.cart-cb').forEach(function (cb) { cb.checked = allCb.checked; });
|
||||||
|
updateState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.cart-cb').forEach(function (cb) {
|
||||||
|
cb.addEventListener('change', updateState);
|
||||||
|
});
|
||||||
|
|
||||||
|
function stripDFC(n) { return n.split(' // ')[0].trim(); }
|
||||||
|
|
||||||
|
function showFallback(text) {
|
||||||
|
var wrap = document.getElementById('cart-fallback-wrap');
|
||||||
|
var ta = document.getElementById('cart-fallback-text');
|
||||||
|
if (!wrap || !ta) return;
|
||||||
|
ta.value = text;
|
||||||
|
wrap.style.display = 'block';
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCartToast(msg) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'cart-toast-top';
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(function () { el.classList.add('hide'); setTimeout(function () { el.remove(); }, 300); }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAfterCopy(url, vendorName) {
|
||||||
|
var names = getCheckedNames();
|
||||||
|
if (!names.length) return;
|
||||||
|
var text = names.map(function (n) { return '1 ' + stripDFC(n); }).join('\n');
|
||||||
|
function doOpen() {
|
||||||
|
showCartToast('List copied — paste into ' + vendorName + ' with Ctrl+V');
|
||||||
|
setTimeout(function () { window.open(url, '_blank'); }, 400);
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(doOpen).catch(function () { showFallback(text); window.open(url, '_blank'); });
|
||||||
|
} else { showFallback(text); window.open(url, '_blank'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.cartCopyTCG = function () { openAfterCopy('https://www.tcgplayer.com/massentry', 'TCGPlayer'); };
|
||||||
|
window.cartCopyCK = function () { openAfterCopy('https://www.cardkingdom.com/builder', 'Card Kingdom'); };
|
||||||
|
|
||||||
|
updateState();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="panel muted">No pickups suggestions available — your deck may already fit the budget well.</div>
|
<div class="panel muted">No upgrade suggestions available — your deck may already fit the budget well.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if budget_report.over_budget_cards %}
|
{% if budget_report.over_budget_cards %}
|
||||||
|
|
@ -102,3 +217,4 @@
|
||||||
<a href="/decks" class="btn" role="button">All Decks</a>
|
<a href="/decks" class="btn" role="button">All Decks</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@
|
||||||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||||||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
|
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
|
||||||
width="320" />
|
width="320" />
|
||||||
|
{# Price overlay — ensures commander price is loaded into window._priceNum for the hover panel #}
|
||||||
|
<div class="card-price-overlay" data-price-for="{{ commander_base }}" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}"
|
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}"
|
||||||
data-original-name="{{ commander }}"
|
data-original-name="{{ commander }}"
|
||||||
|
|
@ -69,12 +71,74 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
||||||
{% if budget_report %}
|
{% 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>
|
<a href="/decks/pickups?name={{ name|urlencode }}" class="btn" role="button" title="View upgrade suggestions for this deck">Upgrade Suggestions</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="get" action="/decks" style="display:inline; margin:0;">
|
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||||
<button type="submit">Back to Finished Decks</button>
|
<button type="submit">Back to Finished Decks</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{# Buy This Deck: collect all cards, strip DFC names, open vendor + copy to clipboard #}
|
||||||
|
{%- set _buy_cards = [] -%}
|
||||||
|
{%- if commander_base -%}
|
||||||
|
{%- set _ = _buy_cards.append({'name': commander_base, 'count': 1}) -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- if summary and summary.type_breakdown and summary.type_breakdown.cards -%}
|
||||||
|
{%- for _btype, _bclist in summary.type_breakdown.cards.items() -%}
|
||||||
|
{%- for _bc in _bclist -%}
|
||||||
|
{%- if _bc.name -%}
|
||||||
|
{%- set _ = _buy_cards.append({'name': _bc.name, 'count': (_bc.count if _bc.count and _bc.count > 1 else 1)}) -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{% if _buy_cards %}
|
||||||
|
<div class="cart-toolbar" style="margin-top:.6rem; flex-direction:column; align-items:flex-start; gap:.35rem;" id="buy-deck-toolbar">
|
||||||
|
<span class="cart-label" style="font-weight:600; color:var(--text,#e5e7eb);">Buy this deck:</span>
|
||||||
|
<div style="display:flex; gap:.4rem; flex-wrap:wrap;">
|
||||||
|
<button class="btn btn-sm" type="button" onclick="buyViaTCG()">Open in TCGPlayer</button>
|
||||||
|
<button class="btn btn-sm" type="button" onclick="buyViaCK()">Open in Card Kingdom</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="buy-fallback-wrap" class="cart-fallback-wrap" style="display:none;">
|
||||||
|
<p>Clipboard access unavailable. Select all and copy the text below, then paste it at the vendor site:</p>
|
||||||
|
<textarea id="buy-fallback-text" class="cart-fallback-textarea" readonly aria-label="Deck list for manual copy"></textarea>
|
||||||
|
<button type="button" class="btn btn-sm" style="margin-top:.4rem;" onclick="document.getElementById('buy-fallback-wrap').style.display='none';">Close</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var _buyCards = {{ _buy_cards | tojson }};
|
||||||
|
function stripDFC(n) { return n.split(' // ')[0].trim(); }
|
||||||
|
function buildList(cards) {
|
||||||
|
return cards.map(function (c) { return c.count + ' ' + stripDFC(c.name); }).join('\n');
|
||||||
|
}
|
||||||
|
function showFallback(text) {
|
||||||
|
var wrap = document.getElementById('buy-fallback-wrap');
|
||||||
|
var ta = document.getElementById('buy-fallback-text');
|
||||||
|
if (!wrap || !ta) return;
|
||||||
|
ta.value = text; wrap.style.display = 'block'; ta.focus(); ta.select();
|
||||||
|
}
|
||||||
|
function showCartToast(msg) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'cart-toast-top';
|
||||||
|
el.textContent = msg;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(function () { el.classList.add('hide'); setTimeout(function () { el.remove(); }, 300); }, 6000);
|
||||||
|
}
|
||||||
|
function openAfterCopy(url, vendorName) {
|
||||||
|
var text = buildList(_buyCards);
|
||||||
|
function doOpen() {
|
||||||
|
showCartToast('List copied — paste into ' + vendorName + ' with Ctrl+V');
|
||||||
|
setTimeout(function () { window.open(url, '_blank'); }, 400);
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(doOpen).catch(function () { showFallback(text); window.open(url, '_blank'); });
|
||||||
|
} else { showFallback(text); window.open(url, '_blank'); }
|
||||||
|
}
|
||||||
|
window.buyViaTCG = function () { openAfterCopy('https://www.tcgplayer.com/massentry', 'TCGPlayer'); };
|
||||||
|
window.buyViaCK = function () { openAfterCopy('https://www.cardkingdom.com/builder', 'Card Kingdom'); };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% if budget_report %}
|
{% if budget_report %}
|
||||||
{% set bstatus = budget_report.budget_status %}
|
{% set bstatus = budget_report.budget_status %}
|
||||||
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-top:.6rem;">
|
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-top:.6rem;">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
<h5>Card Types</h5>
|
<h5>Card Types</h5>
|
||||||
<div class="summary-view-controls">
|
<div class="summary-view-controls">
|
||||||
<span class="muted">View:</span>
|
<span class="muted">View:</span>
|
||||||
|
<span class="price-legend" style="margin-left:auto;">TCG = TCGPlayer · CK = Card Kingdom</span>
|
||||||
<div class="seg" role="tablist" aria-label="Type view">
|
<div class="seg" role="tablist" aria-label="Type view">
|
||||||
<button type="button" class="seg-btn" data-view="list" aria-selected="true" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.remove('hidden');thumbs.classList.add('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=thumbs]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','list');}catch(e){}})(this)">List</button>
|
<button type="button" class="seg-btn" data-view="list" aria-selected="true" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.remove('hidden');thumbs.classList.add('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=thumbs]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','list');}catch(e){}})(this)">List</button>
|
||||||
<button type="button" class="seg-btn" data-view="thumbs" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.add('hidden');thumbs.classList.remove('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=list]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','thumbs');}catch(e){}; (function(){var tv=document.getElementById('typeview-thumbs'); if(!tv) return; tv.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid'); if(!grid) return; var cs=getComputedStyle(sw); var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160; var gap=10; var width=sw.clientWidth; if(!width||width<cardW){ sw.style.setProperty('--cols','1'); return;} var cols=Math.max(1,Math.floor((width+gap)/(cardW+gap))); sw.style.setProperty('--cols',String(cols));}); })();})(this)">Thumbnails</button>
|
<button type="button" class="seg-btn" data-view="thumbs" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.add('hidden');thumbs.classList.remove('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=list]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','thumbs');}catch(e){}; (function(){var tv=document.getElementById('typeview-thumbs'); if(!tv) return; tv.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid'); if(!grid) return; var cs=getComputedStyle(sw); var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160; var gap=10; var width=sw.clientWidth; if(!width||width<cardW){ sw.style.setProperty('--cols','1'); return;} var cols=Math.max(1,Math.floor((width+gap)/(cardW+gap))); sw.style.setProperty('--cols',String(cols));}); })();})(this)">Thumbnails</button>
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,9 @@
|
||||||
{% if theme.example_cards %}
|
{% if theme.example_cards %}
|
||||||
{% for c in theme.example_cards %}
|
{% for c in theme.example_cards %}
|
||||||
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
||||||
<div class="ex-card text-center" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
<div class="ex-card text-center" style="position:relative" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||||
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
|
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
|
||||||
|
<div class="card-price-overlay" data-price-for="{{ base_c }}" aria-hidden="true"></div>
|
||||||
<div class="text-[11px] mt-1 whitespace-nowrap overflow-hidden text-ellipsis font-semibold card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
<div class="text-[11px] mt-1 whitespace-nowrap overflow-hidden text-ellipsis font-semibold card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -198,8 +199,9 @@
|
||||||
{% if theme.example_commanders %}
|
{% if theme.example_commanders %}
|
||||||
{% for c in theme.example_commanders %}
|
{% for c in theme.example_commanders %}
|
||||||
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
||||||
<div class="ex-commander commander-cell text-center" data-card-name="{{ base_c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
<div class="ex-commander commander-cell text-center" style="position:relative" data-card-name="{{ base_c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||||
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
|
<img class="card-thumb w-full h-auto border border-[var(--border)] rounded-[10px]" loading="lazy" decoding="async" alt="{{ c }} image" src="{{ base_c|card_image('small') }}" />
|
||||||
|
<div class="card-price-overlay" data-price-for="{{ base_c }}" aria-hidden="true"></div>
|
||||||
<div class="text-[11px] mt-1 font-semibold whitespace-nowrap overflow-hidden text-ellipsis card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
<div class="text-[11px] mt-1 font-semibold whitespace-nowrap overflow-hidden text-ellipsis card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -4263,10 +4263,10 @@
|
||||||
"theme": "Converge",
|
"theme": "Converge",
|
||||||
"synergies": [
|
"synergies": [
|
||||||
"+1/+1 Counters",
|
"+1/+1 Counters",
|
||||||
"Counters Matter",
|
|
||||||
"Spells Matter",
|
"Spells Matter",
|
||||||
"Spellslinger",
|
"Spellslinger",
|
||||||
"Voltron"
|
"Counters Matter",
|
||||||
|
"Burn"
|
||||||
],
|
],
|
||||||
"primary_color": "Blue",
|
"primary_color": "Blue",
|
||||||
"secondary_color": "Green",
|
"secondary_color": "Green",
|
||||||
|
|
@ -24807,27 +24807,27 @@
|
||||||
],
|
],
|
||||||
"frequencies_by_base_color": {
|
"frequencies_by_base_color": {
|
||||||
"white": {
|
"white": {
|
||||||
"Aggro": 2179,
|
"Aggro": 2180,
|
||||||
"Artifacts Matter": 1123,
|
"Artifacts Matter": 1125,
|
||||||
"Combat Matters": 2179,
|
"Combat Matters": 2180,
|
||||||
"Equip": 70,
|
"Equip": 70,
|
||||||
"Equipment": 73,
|
"Equipment": 73,
|
||||||
"Equipment Matters": 300,
|
"Equipment Matters": 300,
|
||||||
"Voltron": 1363,
|
"Voltron": 1364,
|
||||||
"Big Mana": 1742,
|
"Big Mana": 1742,
|
||||||
"Bird Kindred": 225,
|
"Bird Kindred": 225,
|
||||||
"Blink": 1090,
|
"Blink": 1091,
|
||||||
"Enter the Battlefield": 1090,
|
"Enter the Battlefield": 1091,
|
||||||
"Flying": 1016,
|
"Flying": 1016,
|
||||||
"Guest Kindred": 2,
|
"Guest Kindred": 2,
|
||||||
"Leave the Battlefield": 1101,
|
"Leave the Battlefield": 1102,
|
||||||
"Life Matters": 1672,
|
"Life Matters": 1674,
|
||||||
"Lifegain": 1671,
|
"Lifegain": 1673,
|
||||||
"Little Fellas": 2455,
|
"Little Fellas": 2457,
|
||||||
"Toughness Matters": 1393,
|
"Toughness Matters": 1393,
|
||||||
"Mill": 673,
|
"Mill": 674,
|
||||||
"Spells Matter": 1679,
|
"Spells Matter": 1681,
|
||||||
"Spellslinger": 1679,
|
"Spellslinger": 1681,
|
||||||
"Auras": 468,
|
"Auras": 468,
|
||||||
"Enchantments Matter": 1240,
|
"Enchantments Matter": 1240,
|
||||||
"Ally Kindred": 110,
|
"Ally Kindred": 110,
|
||||||
|
|
@ -24840,8 +24840,8 @@
|
||||||
"Tokens Matter": 1012,
|
"Tokens Matter": 1012,
|
||||||
"Cantrips": 126,
|
"Cantrips": 126,
|
||||||
"Card Draw": 661,
|
"Card Draw": 661,
|
||||||
"Combat Tricks": 272,
|
"Combat Tricks": 273,
|
||||||
"Interaction": 1324,
|
"Interaction": 1327,
|
||||||
"Unconditional Draw": 229,
|
"Unconditional Draw": 229,
|
||||||
"Bending": 23,
|
"Bending": 23,
|
||||||
"Cost Reduction": 135,
|
"Cost Reduction": 135,
|
||||||
|
|
@ -24850,9 +24850,9 @@
|
||||||
"Topdeck": 302,
|
"Topdeck": 302,
|
||||||
"Waterbend": 4,
|
"Waterbend": 4,
|
||||||
"Waterbending": 5,
|
"Waterbending": 5,
|
||||||
"+1/+1 Counters": 754,
|
"+1/+1 Counters": 755,
|
||||||
"Aristocrats": 285,
|
"Aristocrats": 285,
|
||||||
"Counters Matter": 1104,
|
"Counters Matter": 1105,
|
||||||
"Reanimate": 362,
|
"Reanimate": 362,
|
||||||
"Sacrifice Matters": 285,
|
"Sacrifice Matters": 285,
|
||||||
"Vigilance": 430,
|
"Vigilance": 430,
|
||||||
|
|
@ -24869,7 +24869,7 @@
|
||||||
"Reach": 28,
|
"Reach": 28,
|
||||||
"Spirit Kindred": 341,
|
"Spirit Kindred": 341,
|
||||||
"Trample": 90,
|
"Trample": 90,
|
||||||
"Lifelink": 423,
|
"Lifelink": 424,
|
||||||
"Beast Kindred": 60,
|
"Beast Kindred": 60,
|
||||||
"Sloth Kindred": 4,
|
"Sloth Kindred": 4,
|
||||||
"Gargoyle Kindred": 19,
|
"Gargoyle Kindred": 19,
|
||||||
|
|
@ -24882,12 +24882,12 @@
|
||||||
"Choose a background": 5,
|
"Choose a background": 5,
|
||||||
"Soldier Kindred": 842,
|
"Soldier Kindred": 842,
|
||||||
"Warrior Kindred": 307,
|
"Warrior Kindred": 307,
|
||||||
"Control": 379,
|
"Control": 380,
|
||||||
"Toolbox": 143,
|
"Toolbox": 145,
|
||||||
"Bard Kindred": 14,
|
"Bard Kindred": 14,
|
||||||
"First strike": 184,
|
"First strike": 184,
|
||||||
"Removal": 592,
|
"Removal": 594,
|
||||||
"Burn": 513,
|
"Burn": 514,
|
||||||
"Deserts Matter": 14,
|
"Deserts Matter": 14,
|
||||||
"Land Types Matter": 92,
|
"Land Types Matter": 92,
|
||||||
"Pingers": 98,
|
"Pingers": 98,
|
||||||
|
|
@ -24946,7 +24946,7 @@
|
||||||
"Convoke": 39,
|
"Convoke": 39,
|
||||||
"Artificer Kindred": 82,
|
"Artificer Kindred": 82,
|
||||||
"Vehicles": 90,
|
"Vehicles": 90,
|
||||||
"Dwarf Kindred": 64,
|
"Dwarf Kindred": 65,
|
||||||
"Crew": 25,
|
"Crew": 25,
|
||||||
"Elephant Kindred": 43,
|
"Elephant Kindred": 43,
|
||||||
"Performer Kindred": 7,
|
"Performer Kindred": 7,
|
||||||
|
|
@ -25007,8 +25007,8 @@
|
||||||
"Mount Kindred": 24,
|
"Mount Kindred": 24,
|
||||||
"Deathtouch": 25,
|
"Deathtouch": 25,
|
||||||
"Faerie Kindred": 20,
|
"Faerie Kindred": 20,
|
||||||
"Outlaw Kindred": 101,
|
"Outlaw Kindred": 102,
|
||||||
"Warlock Kindred": 28,
|
"Warlock Kindred": 29,
|
||||||
"Golem Kindred": 25,
|
"Golem Kindred": 25,
|
||||||
"Flurry": 6,
|
"Flurry": 6,
|
||||||
"Elf Kindred": 90,
|
"Elf Kindred": 90,
|
||||||
|
|
@ -26276,20 +26276,20 @@
|
||||||
"Enter the Battlefield": 1152,
|
"Enter the Battlefield": 1152,
|
||||||
"Guest Kindred": 6,
|
"Guest Kindred": 6,
|
||||||
"Leave the Battlefield": 1153,
|
"Leave the Battlefield": 1153,
|
||||||
"Little Fellas": 2024,
|
"Little Fellas": 2026,
|
||||||
"Mill": 1566,
|
"Mill": 1567,
|
||||||
"Open an Attraction": 9,
|
"Open an Attraction": 9,
|
||||||
"Reanimate": 1447,
|
"Reanimate": 1447,
|
||||||
"Roll to Visit Your Attractions": 2,
|
"Roll to Visit Your Attractions": 2,
|
||||||
"Zombie Kindred": 641,
|
"Zombie Kindred": 641,
|
||||||
"+1/+1 Counters": 668,
|
"+1/+1 Counters": 669,
|
||||||
"Aggro": 2049,
|
"Aggro": 2050,
|
||||||
"Aristocrats": 957,
|
"Aristocrats": 957,
|
||||||
"Artifacts Matter": 794,
|
"Artifacts Matter": 795,
|
||||||
"Big Mana": 1984,
|
"Big Mana": 1985,
|
||||||
"Burn": 1375,
|
"Burn": 1376,
|
||||||
"Combat Matters": 2049,
|
"Combat Matters": 2050,
|
||||||
"Counters Matter": 1071,
|
"Counters Matter": 1072,
|
||||||
"Creature Tokens": 579,
|
"Creature Tokens": 579,
|
||||||
"Druid Kindred": 36,
|
"Druid Kindred": 36,
|
||||||
"Historics Matter": 1162,
|
"Historics Matter": 1162,
|
||||||
|
|
@ -26299,19 +26299,19 @@
|
||||||
"Sacrifice Matters": 953,
|
"Sacrifice Matters": 953,
|
||||||
"Token Creation": 774,
|
"Token Creation": 774,
|
||||||
"Tokens Matter": 779,
|
"Tokens Matter": 779,
|
||||||
"Voltron": 1001,
|
"Voltron": 1002,
|
||||||
"Astartes Kindred": 11,
|
"Astartes Kindred": 11,
|
||||||
"Cascade": 10,
|
"Cascade": 10,
|
||||||
"Exile Matters": 227,
|
"Exile Matters": 227,
|
||||||
"Mark of Chaos Ascendant": 1,
|
"Mark of Chaos Ascendant": 1,
|
||||||
"Trample": 141,
|
"Trample": 141,
|
||||||
"Warrior Kindred": 298,
|
"Warrior Kindred": 298,
|
||||||
"Spells Matter": 1903,
|
"Spells Matter": 1907,
|
||||||
"Spellslinger": 1903,
|
"Spellslinger": 1907,
|
||||||
"X Spells": 221,
|
"X Spells": 221,
|
||||||
"First strike": 62,
|
"First strike": 62,
|
||||||
"Life Matters": 1311,
|
"Life Matters": 1315,
|
||||||
"Lifegain": 1308,
|
"Lifegain": 1312,
|
||||||
"Toughness Matters": 972,
|
"Toughness Matters": 972,
|
||||||
"-1/-1 Counters": 164,
|
"-1/-1 Counters": 164,
|
||||||
"Demon Kindred": 219,
|
"Demon Kindred": 219,
|
||||||
|
|
@ -26323,7 +26323,7 @@
|
||||||
"Bird Kindred": 53,
|
"Bird Kindred": 53,
|
||||||
"Lifelink": 304,
|
"Lifelink": 304,
|
||||||
"Combat Tricks": 211,
|
"Combat Tricks": 211,
|
||||||
"Interaction": 1227,
|
"Interaction": 1229,
|
||||||
"Midrange": 122,
|
"Midrange": 122,
|
||||||
"Horror Kindred": 239,
|
"Horror Kindred": 239,
|
||||||
"Card Draw": 1043,
|
"Card Draw": 1043,
|
||||||
|
|
@ -26335,7 +26335,7 @@
|
||||||
"Elf Kindred": 114,
|
"Elf Kindred": 114,
|
||||||
"Menace": 232,
|
"Menace": 232,
|
||||||
"Vigilance": 59,
|
"Vigilance": 59,
|
||||||
"Removal": 741,
|
"Removal": 743,
|
||||||
"Basic landcycling": 4,
|
"Basic landcycling": 4,
|
||||||
"Cycling": 83,
|
"Cycling": 83,
|
||||||
"Landcycling": 15,
|
"Landcycling": 15,
|
||||||
|
|
@ -26357,10 +26357,10 @@
|
||||||
"Stax": 401,
|
"Stax": 401,
|
||||||
"Specter Kindred": 27,
|
"Specter Kindred": 27,
|
||||||
"Enchantments Matter": 807,
|
"Enchantments Matter": 807,
|
||||||
"Spirit Kindred": 221,
|
"Spirit Kindred": 222,
|
||||||
"Mana Rock": 67,
|
"Mana Rock": 67,
|
||||||
"Sacrifice to Draw": 151,
|
"Sacrifice to Draw": 151,
|
||||||
"Toolbox": 123,
|
"Toolbox": 124,
|
||||||
"Unconditional Draw": 248,
|
"Unconditional Draw": 248,
|
||||||
"Cleric Kindred": 183,
|
"Cleric Kindred": 183,
|
||||||
"Dog Kindred": 24,
|
"Dog Kindred": 24,
|
||||||
|
|
@ -26475,13 +26475,14 @@
|
||||||
"Lord of the Pyrrhian Legions": 1,
|
"Lord of the Pyrrhian Legions": 1,
|
||||||
"Necron Kindred": 25,
|
"Necron Kindred": 25,
|
||||||
"Hellbent": 15,
|
"Hellbent": 15,
|
||||||
"Frog Kindred": 17,
|
"Frog Kindred": 18,
|
||||||
"Landwalk": 53,
|
"Landwalk": 53,
|
||||||
"Swampwalk": 29,
|
"Swampwalk": 29,
|
||||||
"Gorgon Kindred": 27,
|
"Gorgon Kindred": 27,
|
||||||
"Snake Kindred": 42,
|
"Snake Kindred": 42,
|
||||||
"Crew": 19,
|
"Crew": 19,
|
||||||
"Merfolk Kindred": 17,
|
"Merfolk Kindred": 17,
|
||||||
|
"Converge": 4,
|
||||||
"Modular": 1,
|
"Modular": 1,
|
||||||
"Turtle Kindred": 8,
|
"Turtle Kindred": 8,
|
||||||
"Defender": 34,
|
"Defender": 34,
|
||||||
|
|
@ -26933,7 +26934,6 @@
|
||||||
"Eon Counters": 1,
|
"Eon Counters": 1,
|
||||||
"Impending": 1,
|
"Impending": 1,
|
||||||
"Toy Kindred": 2,
|
"Toy Kindred": 2,
|
||||||
"Converge": 3,
|
|
||||||
"Fade Counters": 3,
|
"Fade Counters": 3,
|
||||||
"Fading": 3,
|
"Fading": 3,
|
||||||
"Barbarian Kindred": 5,
|
"Barbarian Kindred": 5,
|
||||||
|
|
@ -27036,20 +27036,20 @@
|
||||||
},
|
},
|
||||||
"red": {
|
"red": {
|
||||||
"Burn": 2169,
|
"Burn": 2169,
|
||||||
"Enchantments Matter": 781,
|
"Enchantments Matter": 780,
|
||||||
"Blink": 730,
|
"Blink": 731,
|
||||||
"Enter the Battlefield": 730,
|
"Enter the Battlefield": 731,
|
||||||
"Goblin Kindred": 480,
|
"Goblin Kindred": 480,
|
||||||
"Guest Kindred": 4,
|
"Guest Kindred": 4,
|
||||||
"Leave the Battlefield": 731,
|
"Leave the Battlefield": 732,
|
||||||
"Little Fellas": 1890,
|
"Little Fellas": 1890,
|
||||||
"Mana Dork": 161,
|
"Mana Dork": 161,
|
||||||
"Ramp": 295,
|
"Ramp": 295,
|
||||||
"Aggro": 2358,
|
"Aggro": 2357,
|
||||||
"Astartes Kindred": 8,
|
"Astartes Kindred": 8,
|
||||||
"Big Mana": 2058,
|
"Big Mana": 2059,
|
||||||
"Cascade": 28,
|
"Cascade": 28,
|
||||||
"Combat Matters": 2358,
|
"Combat Matters": 2357,
|
||||||
"Exile Matters": 373,
|
"Exile Matters": 373,
|
||||||
"Historics Matter": 1205,
|
"Historics Matter": 1205,
|
||||||
"Legends Matter": 1205,
|
"Legends Matter": 1205,
|
||||||
|
|
@ -27058,16 +27058,16 @@
|
||||||
"Warrior Kindred": 569,
|
"Warrior Kindred": 569,
|
||||||
"Card Draw": 725,
|
"Card Draw": 725,
|
||||||
"Discard Matters": 465,
|
"Discard Matters": 465,
|
||||||
"Spells Matter": 2178,
|
"Spells Matter": 2180,
|
||||||
"Spellslinger": 2178,
|
"Spellslinger": 2180,
|
||||||
"Unconditional Draw": 255,
|
"Unconditional Draw": 255,
|
||||||
"Combat Tricks": 212,
|
"Combat Tricks": 213,
|
||||||
"Interaction": 967,
|
"Interaction": 968,
|
||||||
"Madness": 20,
|
"Madness": 20,
|
||||||
"Mill": 634,
|
"Mill": 634,
|
||||||
"Reanimate": 479,
|
"Reanimate": 478,
|
||||||
"Flashback": 69,
|
"Flashback": 69,
|
||||||
"Artifacts Matter": 1148,
|
"Artifacts Matter": 1149,
|
||||||
"Human Kindred": 1085,
|
"Human Kindred": 1085,
|
||||||
"Impulse": 202,
|
"Impulse": 202,
|
||||||
"Monk Kindred": 42,
|
"Monk Kindred": 42,
|
||||||
|
|
@ -27082,7 +27082,7 @@
|
||||||
"Tokens Matter": 795,
|
"Tokens Matter": 795,
|
||||||
"Zombie Kindred": 58,
|
"Zombie Kindred": 58,
|
||||||
"Removal": 351,
|
"Removal": 351,
|
||||||
"Toolbox": 137,
|
"Toolbox": 138,
|
||||||
"Deserts Matter": 14,
|
"Deserts Matter": 14,
|
||||||
"Land Types Matter": 80,
|
"Land Types Matter": 80,
|
||||||
"Lands Matter": 590,
|
"Lands Matter": 590,
|
||||||
|
|
@ -27097,14 +27097,14 @@
|
||||||
"Wheels": 145,
|
"Wheels": 145,
|
||||||
"+1/+1 Counters": 535,
|
"+1/+1 Counters": 535,
|
||||||
"Renown": 5,
|
"Renown": 5,
|
||||||
"Voltron": 916,
|
"Voltron": 915,
|
||||||
"Auras": 245,
|
"Auras": 244,
|
||||||
"Enchant": 181,
|
"Enchant": 180,
|
||||||
"Goad": 49,
|
"Goad": 49,
|
||||||
"Rad Counters": 2,
|
"Rad Counters": 2,
|
||||||
"Stax": 508,
|
"Stax": 508,
|
||||||
"Theft": 179,
|
"Theft": 179,
|
||||||
"Control": 289,
|
"Control": 290,
|
||||||
"Spirit Kindred": 119,
|
"Spirit Kindred": 119,
|
||||||
"Clash": 5,
|
"Clash": 5,
|
||||||
"Flying": 496,
|
"Flying": 496,
|
||||||
|
|
@ -27199,7 +27199,7 @@
|
||||||
"Raid": 24,
|
"Raid": 24,
|
||||||
"Golem Kindred": 20,
|
"Golem Kindred": 20,
|
||||||
"Scry": 68,
|
"Scry": 68,
|
||||||
"Topdeck": 270,
|
"Topdeck": 271,
|
||||||
"Infect": 12,
|
"Infect": 12,
|
||||||
"Ore Counters": 65,
|
"Ore Counters": 65,
|
||||||
"Vampire Kindred": 96,
|
"Vampire Kindred": 96,
|
||||||
|
|
@ -27255,6 +27255,7 @@
|
||||||
"Living metal": 6,
|
"Living metal": 6,
|
||||||
"More Than Meets the Eye": 7,
|
"More Than Meets the Eye": 7,
|
||||||
"Robot Kindred": 40,
|
"Robot Kindred": 40,
|
||||||
|
"Converge": 4,
|
||||||
"Verse Counters": 3,
|
"Verse Counters": 3,
|
||||||
"Sphinx Kindred": 2,
|
"Sphinx Kindred": 2,
|
||||||
"Wolf Kindred": 30,
|
"Wolf Kindred": 30,
|
||||||
|
|
@ -27635,7 +27636,6 @@
|
||||||
"Training": 2,
|
"Training": 2,
|
||||||
"Ingenuity Counters": 1,
|
"Ingenuity Counters": 1,
|
||||||
"Token Modification": 4,
|
"Token Modification": 4,
|
||||||
"Converge": 3,
|
|
||||||
"Dryad Kindred": 1,
|
"Dryad Kindred": 1,
|
||||||
"Symphony of Pain": 1,
|
"Symphony of Pain": 1,
|
||||||
"Sigil of Corruption": 1,
|
"Sigil of Corruption": 1,
|
||||||
|
|
@ -27786,7 +27786,7 @@
|
||||||
"Token Creation": 910,
|
"Token Creation": 910,
|
||||||
"Tokens Matter": 921,
|
"Tokens Matter": 921,
|
||||||
"Ally Kindred": 55,
|
"Ally Kindred": 55,
|
||||||
"Artifacts Matter": 764,
|
"Artifacts Matter": 765,
|
||||||
"Avatar Kindred": 55,
|
"Avatar Kindred": 55,
|
||||||
"Historics Matter": 1081,
|
"Historics Matter": 1081,
|
||||||
"Legends Matter": 1081,
|
"Legends Matter": 1081,
|
||||||
|
|
@ -27812,11 +27812,11 @@
|
||||||
"Survivor Kindred": 9,
|
"Survivor Kindred": 9,
|
||||||
"Zombie Kindred": 60,
|
"Zombie Kindred": 60,
|
||||||
"Heavy Power Hammer": 1,
|
"Heavy Power Hammer": 1,
|
||||||
"Interaction": 854,
|
"Interaction": 855,
|
||||||
"Little Fellas": 2037,
|
"Little Fellas": 2038,
|
||||||
"Mutant Kindred": 83,
|
"Mutant Kindred": 83,
|
||||||
"Ravenous": 9,
|
"Ravenous": 9,
|
||||||
"Removal": 423,
|
"Removal": 424,
|
||||||
"Tyranid Kindred": 28,
|
"Tyranid Kindred": 28,
|
||||||
"X Spells": 281,
|
"X Spells": 281,
|
||||||
"Stax": 431,
|
"Stax": 431,
|
||||||
|
|
@ -27835,14 +27835,14 @@
|
||||||
"Age Counters": 26,
|
"Age Counters": 26,
|
||||||
"Cumulative upkeep": 20,
|
"Cumulative upkeep": 20,
|
||||||
"Elemental Kindred": 241,
|
"Elemental Kindred": 241,
|
||||||
"Spells Matter": 1592,
|
"Spells Matter": 1593,
|
||||||
"Spellslinger": 1592,
|
"Spellslinger": 1593,
|
||||||
"Unconditional Draw": 268,
|
"Unconditional Draw": 268,
|
||||||
"Auras": 309,
|
"Auras": 309,
|
||||||
"Cantrips": 122,
|
"Cantrips": 122,
|
||||||
"Enchant": 224,
|
"Enchant": 224,
|
||||||
"Midrange": 173,
|
"Midrange": 173,
|
||||||
"Spirit Kindred": 140,
|
"Spirit Kindred": 141,
|
||||||
"Mana Rock": 68,
|
"Mana Rock": 68,
|
||||||
"Ramp": 767,
|
"Ramp": 767,
|
||||||
"Sacrifice to Draw": 84,
|
"Sacrifice to Draw": 84,
|
||||||
|
|
@ -27850,8 +27850,8 @@
|
||||||
"Shaman Kindred": 201,
|
"Shaman Kindred": 201,
|
||||||
"Toolbox": 185,
|
"Toolbox": 185,
|
||||||
"Cleric Kindred": 62,
|
"Cleric Kindred": 62,
|
||||||
"Life Matters": 657,
|
"Life Matters": 659,
|
||||||
"Lifegain": 657,
|
"Lifegain": 659,
|
||||||
"Mana Dork": 313,
|
"Mana Dork": 313,
|
||||||
"Lifelink": 76,
|
"Lifelink": 76,
|
||||||
"Warrior Kindred": 438,
|
"Warrior Kindred": 438,
|
||||||
|
|
@ -27978,7 +27978,7 @@
|
||||||
"Cascade": 17,
|
"Cascade": 17,
|
||||||
"Heroic": 6,
|
"Heroic": 6,
|
||||||
"Rooms Matter": 6,
|
"Rooms Matter": 6,
|
||||||
"Frog Kindred": 53,
|
"Frog Kindred": 54,
|
||||||
"Threshold": 26,
|
"Threshold": 26,
|
||||||
"God Kindred": 22,
|
"God Kindred": 22,
|
||||||
"Mole Kindred": 9,
|
"Mole Kindred": 9,
|
||||||
|
|
@ -28468,12 +28468,12 @@
|
||||||
"generated_from": "merge (analytics + curated YAML + whitelist)",
|
"generated_from": "merge (analytics + curated YAML + whitelist)",
|
||||||
"metadata_info": {
|
"metadata_info": {
|
||||||
"mode": "merge",
|
"mode": "merge",
|
||||||
"generated_at": "2026-03-27T17:14:22",
|
"generated_at": "2026-04-04T04:35:11",
|
||||||
"curated_yaml_files": 735,
|
"curated_yaml_files": 762,
|
||||||
"synergy_cap": 5,
|
"synergy_cap": 5,
|
||||||
"inference": "pmi",
|
"inference": "pmi",
|
||||||
"version": "phase-b-merge-v1",
|
"version": "phase-b-merge-v1",
|
||||||
"catalog_hash": "432b90659a971abc5c1a2335fac603c798399e556f4b4c14098795e18f398426"
|
"catalog_hash": "a43e47eaac087fc871542d09d2dce9b163bea5c37d5dd010661996080caf9258"
|
||||||
},
|
},
|
||||||
"description_fallback_summary": null
|
"description_fallback_summary": null
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue