mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 22:16:31 +01:00
feat: add Budget Mode with price cache infrastructure and stale price warnings
This commit is contained in:
parent
1aa8e4d7e8
commit
ec23775205
42 changed files with 6976 additions and 2753 deletions
|
|
@ -118,6 +118,7 @@
|
|||
Card images and data provided by
|
||||
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
|
||||
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
|
||||
{% set _pba = _price_cache_ts() %}{% if _pba %}<br><span class="muted" style="font-size:.8em;">Prices as of {{ _pba }} — for live pricing visit <a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.</span>{% endif %}
|
||||
</footer>
|
||||
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
|
||||
<style>
|
||||
|
|
@ -388,5 +389,164 @@
|
|||
{% endif %}
|
||||
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
|
||||
<!-- Hover card panel system moved to cardHover.ts -->
|
||||
<!-- Price tooltip: lightweight fetch on mouseenter for .card-name-price-hover -->
|
||||
<script>
|
||||
(function(){
|
||||
var _priceCache = {};
|
||||
var _tip = null;
|
||||
function _showTip(el, text) {
|
||||
if (!_tip) {
|
||||
_tip = document.createElement('div');
|
||||
_tip.className = 'card-price-tip';
|
||||
document.body.appendChild(_tip);
|
||||
}
|
||||
_tip.textContent = text;
|
||||
var r = el.getBoundingClientRect();
|
||||
_tip.style.left = (r.left + r.width/2 + window.scrollX) + 'px';
|
||||
_tip.style.top = (r.top + window.scrollY - 4) + 'px';
|
||||
_tip.style.transform = 'translate(-50%, -100%)';
|
||||
_tip.style.display = 'block';
|
||||
}
|
||||
function _hideTip() { if (_tip) _tip.style.display = 'none'; }
|
||||
document.addEventListener('mouseover', function(e) {
|
||||
var el = e.target && e.target.closest && e.target.closest('.card-name-price-hover');
|
||||
if (!el) return;
|
||||
var name = el.dataset.cardName || el.textContent.trim();
|
||||
if (!name) return;
|
||||
if (_priceCache[name] !== undefined) {
|
||||
_showTip(el, _priceCache[name]);
|
||||
return;
|
||||
}
|
||||
_showTip(el, 'Loading price...');
|
||||
fetch('/api/price/' + encodeURIComponent(name))
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(d){
|
||||
var label = (d && d.found && d.price != null) ? ('$' + parseFloat(d.price).toFixed(2)) : 'Price unavailable';
|
||||
_priceCache[name] = label;
|
||||
_showTip(el, label);
|
||||
})
|
||||
.catch(function(){ _priceCache[name] = 'Price unavailable'; });
|
||||
});
|
||||
document.addEventListener('mouseout', function(e) {
|
||||
var el = e.target && e.target.closest && e.target.closest('.card-name-price-hover');
|
||||
if (el) _hideTip();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<!-- Budget price display: injects prices on card tiles and list rows, tracks running total -->
|
||||
<script>
|
||||
(function(){
|
||||
var BASIC_LANDS = new Set([
|
||||
'Plains','Island','Swamp','Mountain','Forest','Wastes',
|
||||
'Snow-Covered Plains','Snow-Covered Island','Snow-Covered Swamp',
|
||||
'Snow-Covered Mountain','Snow-Covered Forest'
|
||||
]);
|
||||
var _priceNum = {}; // card name -> float|null
|
||||
var _deckPrices = {}; // accumulated across build stages: card name -> float
|
||||
var _buildToken = null;
|
||||
function _fetchNum(name) {
|
||||
if (_priceNum.hasOwnProperty(name)) return Promise.resolve(_priceNum[name]);
|
||||
return fetch('/api/price/' + encodeURIComponent(name))
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(d){
|
||||
var p = (d && d.found && d.price != null) ? parseFloat(d.price) : null;
|
||||
_priceNum[name] = p; return p;
|
||||
}).catch(function(){ _priceNum[name] = null; return null; });
|
||||
}
|
||||
function _getBuildToken() {
|
||||
var el = document.querySelector('[data-build-id]');
|
||||
return el ? el.getAttribute('data-build-id') : null;
|
||||
}
|
||||
function _cfg() { return window._budgetCfg || null; }
|
||||
function initPriceDisplay() {
|
||||
var tok = _getBuildToken();
|
||||
if (tok !== null && tok !== _buildToken) { _buildToken = tok; _deckPrices = {}; }
|
||||
var cfg = _cfg();
|
||||
var ceiling = cfg && cfg.card_ceiling ? parseFloat(cfg.card_ceiling) : null;
|
||||
var totalCap = cfg && cfg.total ? parseFloat(cfg.total) : null;
|
||||
|
||||
function updateRunningTotal(prevTotal) {
|
||||
var chip = document.getElementById('budget-running');
|
||||
if (!chip) return;
|
||||
var total = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
||||
chip.textContent = total.toFixed(2);
|
||||
var chipWrap = chip.closest('.chip');
|
||||
if (chipWrap && totalCap !== null) chipWrap.classList.toggle('chip-warn', total > totalCap);
|
||||
if (prevTotal !== undefined) {
|
||||
var stepAdded = total - prevTotal;
|
||||
var stepEl = document.getElementById('budget-step');
|
||||
if (stepEl && stepAdded > 0.005) {
|
||||
stepEl.textContent = '+$' + stepAdded.toFixed(2) + ' this step';
|
||||
stepEl.style.display = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var overlays = document.querySelectorAll('.card-price-overlay[data-price-for]');
|
||||
var inlines = document.querySelectorAll('.card-price-inline[data-price-for]');
|
||||
var toFetch = new Set();
|
||||
overlays.forEach(function(el){ var n = el.dataset.priceFor; if (n && !BASIC_LANDS.has(n)) toFetch.add(n); });
|
||||
inlines.forEach(function(el){ var n = el.dataset.priceFor; if (n && !BASIC_LANDS.has(n)) toFetch.add(n); });
|
||||
|
||||
// Always refresh the running total chip even when there's nothing new to fetch
|
||||
updateRunningTotal();
|
||||
if (!toFetch.size) return;
|
||||
|
||||
var prevTotal = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
||||
var promises = [];
|
||||
toFetch.forEach(function(name){ promises.push(_fetchNum(name).then(function(p){ return {name:name,price:p}; })); });
|
||||
Promise.all(promises).then(function(results){
|
||||
var map = {};
|
||||
var prevTotal2 = Object.values(_deckPrices).reduce(function(s,p){ return s + (p||0); }, 0);
|
||||
results.forEach(function(r){ map[r.name] = r.price; if (r.price !== null) _deckPrices[r.name] = r.price; });
|
||||
overlays.forEach(function(el){
|
||||
var name = el.dataset.priceFor;
|
||||
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
||||
var p = map[name];
|
||||
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
|
||||
if (ceiling !== null && p !== null && p > ceiling) {
|
||||
var tile = el.closest('.card-tile,.stack-card');
|
||||
if (tile) tile.classList.add('over-budget');
|
||||
}
|
||||
});
|
||||
inlines.forEach(function(el){
|
||||
var name = el.dataset.priceFor;
|
||||
if (!name || BASIC_LANDS.has(name)) { el.style.display='none'; return; }
|
||||
var p = map[name];
|
||||
el.textContent = p !== null ? ('$' + p.toFixed(2)) : '';
|
||||
if (ceiling !== null && p !== null && p > ceiling) {
|
||||
var row = el.closest('.list-row');
|
||||
if (row) row.classList.add('over-budget');
|
||||
}
|
||||
});
|
||||
// Update running total chip with per-step delta
|
||||
updateRunningTotal(prevTotal2);
|
||||
// Update summary budget bar
|
||||
var bar = document.getElementById('budget-summary-bar');
|
||||
if (bar) {
|
||||
var allNames = new Set();
|
||||
var sumTotal = 0;
|
||||
document.querySelectorAll('.card-price-overlay[data-price-for],.card-price-inline[data-price-for]').forEach(function(el){
|
||||
var n = el.dataset.priceFor;
|
||||
if (n && !BASIC_LANDS.has(n) && !allNames.has(n)) {
|
||||
allNames.add(n);
|
||||
sumTotal += (map[n] || 0);
|
||||
}
|
||||
});
|
||||
if (totalCap !== null) {
|
||||
var over = sumTotal > totalCap;
|
||||
bar.textContent = 'Estimated deck cost: $' + sumTotal.toFixed(2) + ' / $' + totalCap.toFixed(2) + (over ? ' — over budget' : ' — under budget');
|
||||
bar.className = over ? 'budget-price-bar over' : 'budget-price-bar under';
|
||||
} else {
|
||||
bar.textContent = 'Estimated deck cost: $' + sumTotal.toFixed(2) + ' (basic lands excluded)';
|
||||
bar.className = 'budget-price-bar';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function(){ initPriceDisplay(); });
|
||||
document.addEventListener('htmx:afterSwap', function(){ setTimeout(initPriceDisplay, 80); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -32,13 +32,14 @@
|
|||
{% if it.rarity %}data-rarity="{{ it.rarity }}"{% endif %}
|
||||
{% if it.hover_simple %}data-hover-simple="1"{% endif %}
|
||||
{% if it.owned %}data-owned="1"{% endif %}
|
||||
{% if it.price %}data-price="{{ it.price }}"{% endif %}
|
||||
data-tags="{{ tags|join(', ') }}"
|
||||
hx-post="/build/replace"
|
||||
hx-vals='{"old":"{{ name }}", "new":"{{ it.name }}", "owned_only":"{{ 1 if require_owned else 0 }}"}'
|
||||
hx-target="closest .alts"
|
||||
hx-swap="outerHTML"
|
||||
title="Lock this alternative and unlock the current pick">
|
||||
{{ it.name }}
|
||||
{{ it.name }}{% if it.price %} <span style="font-size:11px;opacity:.7;font-weight:normal;">${{ "%.2f"|format(it.price|float) }}</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
|
|||
79
code/web/templates/build/_budget_review.html
Normal file
79
code/web/templates/build/_budget_review.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{% if budget_review_visible %}
|
||||
<div id="budget-review-panel" class="budget-review-panel mt-4">
|
||||
<div class="budget-review-header">
|
||||
<strong>Budget Review</strong>
|
||||
<span class="budget-review-summary">
|
||||
Your deck costs <strong>${{ '%.2f'|format(budget_review_total|float) }}</strong>
|
||||
{% if over_budget_review %}
|
||||
— {{ budget_overage_pct }}% over your ${{ '%.2f'|format(budget_review_cap|float) }} cap.
|
||||
{% else %}
|
||||
— within your ${{ '%.2f'|format(budget_review_cap|float) }} cap.
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if over_budget_review %}
|
||||
<span class="chip chip-yellow">Advisory: deck is over budget</span>
|
||||
{% else %}
|
||||
<span class="chip chip-green">Within budget</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if over_budget_cards %}
|
||||
<div class="budget-review-cards">
|
||||
<p class="muted budget-review-subtitle">Most expensive cards — swapping saves the most on total cost:</p>
|
||||
{% for card in over_budget_cards %}
|
||||
<div class="budget-review-card-row">
|
||||
<div class="budget-review-card-info">
|
||||
<span class="budget-review-card-name"
|
||||
data-card-name="{{ card.name }}"
|
||||
data-role="{{ card.card_role }}"
|
||||
data-tags="{{ card.card_tags|join(', ') if card.card_tags else '' }}"
|
||||
style="cursor:default;">{{ card.name }}</span>
|
||||
<span class="budget-review-card-price">${{ '%.2f'|format(card.price|float) }}</span>{% if stale_prices is defined and card.name|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">⏱</span>{% endif %}
|
||||
{% if card.card_type %}
|
||||
<span class="chip chip-subtle text-xs" title="Card type">{{ card.card_type }}</span>
|
||||
{% endif %}
|
||||
{% if card.card_role %}
|
||||
<span class="chip chip-subtle text-xs" title="Role in deck">{{ card.card_role }}</span>
|
||||
{% endif %}
|
||||
{% if card.swap_disabled %}
|
||||
<span class="chip" title="This card is in your include list">Required</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not card.swap_disabled and card.alternatives %}
|
||||
<div class="budget-review-alts">
|
||||
<span class="muted">Swap for:</span>
|
||||
{% for alt in card.alternatives %}
|
||||
<form hx-post="/build/budget-swap"
|
||||
hx-target="#budget-review-panel"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-form">
|
||||
<input type="hidden" name="old_card" value="{{ card.name }}" />
|
||||
<input type="hidden" name="new_card" value="{{ alt.name }}" />
|
||||
<button type="submit" class="btn-alt-swap"
|
||||
data-card-name="{{ alt.name }}"
|
||||
data-hover-simple="1"
|
||||
{% if alt.price %}data-price="{{ alt.price }}"{% endif %}
|
||||
title="{{ alt.shared_tags|join(', ') if alt.shared_tags else '' }}">
|
||||
{{ alt.name }}
|
||||
{% if alt.price %}<span class="alt-price">${{ '%.2f'|format(alt.price|float) }}</span>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif not card.swap_disabled %}
|
||||
<div class="muted budget-review-no-alts">No affordable alternatives found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Price data unavailable for over-budget cards.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="budget-review-actions mt-2">
|
||||
<button type="button"
|
||||
onclick="document.getElementById('budget-review-panel').remove()"
|
||||
class="btn">Accept deck as-is</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -208,6 +208,34 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
{% include "build/_new_deck_skip_controls.html" %}
|
||||
{% if enable_budget_mode %}
|
||||
<fieldset>
|
||||
<legend>Budget</legend>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="block">
|
||||
<span>Total budget ($)</span>
|
||||
<small class="muted block text-xs mt-1">Set a deck cost ceiling — cards over budget will be flagged</small>
|
||||
<input type="number" name="budget_total" id="budget_total" min="0" step="0.01"
|
||||
placeholder="e.g. 150.00"
|
||||
value="{{ form.budget_total if form and form.budget_total else '' }}" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span>Per-card ceiling ($) <small class="muted">(optional)</small></span>
|
||||
<small class="muted block text-xs mt-1">Flag individual cards above this price</small>
|
||||
<input type="number" name="card_ceiling" id="card_ceiling" min="0" step="0.01"
|
||||
placeholder="e.g. 10.00"
|
||||
value="{{ form.card_ceiling if form and form.card_ceiling else '' }}" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span>Pool filter tolerance (%)</span>
|
||||
<small class="muted block text-xs mt-1">Cards exceeding the per-card ceiling by more than this % are excluded from the card pool. Set to 0 to hard-cap at the ceiling exactly.</small>
|
||||
<input type="number" name="pool_tolerance" id="pool_tolerance" min="0" max="100" step="1"
|
||||
value="{{ form.pool_tolerance if form is not none else '15' }}" />
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
{% if enable_batch_build %}
|
||||
<fieldset>
|
||||
<legend>Build Options</legend>
|
||||
|
|
@ -238,7 +266,7 @@
|
|||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<div class="modal-footer-left">
|
||||
<button type="submit" name="quick_build" value="1" class="btn-continue" id="quick-build-btn" title="Build entire deck automatically without approval steps">Quick Build</button>
|
||||
<button type="submit" class="btn-continue" id="create-btn">Create</button>
|
||||
<button type="submit" class="btn-continue" id="create-btn">Build Deck</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if allow_must_haves and multi_copy_archetypes_js %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{# Quick Build Progress Indicator - Current Stage + Completed List #}
|
||||
|
||||
<div id="wizard" class="wizard-container" style="max-width:1200px; margin:2rem auto; padding:2rem;">
|
||||
<div class="wizard-container" style="max-width:1200px; margin:2rem auto; padding:2rem;">
|
||||
<div id="wizard-content">
|
||||
{% include "build/_quick_build_progress_content.html" %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
{% set hover_tags_joined = hover_tags_source|join(', ') %}
|
||||
{% set display_tags = display_tags_source if display_tags_source else [] %}
|
||||
{% set show_color_identity = color_label or (color_identity_list|length > 0) %}
|
||||
<section>
|
||||
<section data-build-id="{{ build_id }}">
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
|
|
@ -186,6 +186,10 @@
|
|||
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
|
||||
{% endif %}
|
||||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||||
{% if budget_config and budget_config.total %}
|
||||
<span class="chip" id="budget-chip"><span class="dot dot-yellow"></span> $<span id="budget-running">...</span> / ${{ '%.2f'|format(budget_config.total|float) }} cap</span>
|
||||
<span id="budget-step" class="muted text-xs" style="display:none"></span>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||
|
|
@ -214,6 +218,67 @@
|
|||
<div hx-get="/build/compliance" hx-trigger="load" hx-swap="afterend"></div>
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
|
||||
{# M5: Budget review panel — shown in main content when deck total exceeds cap #}
|
||||
{% include 'build/_budget_review.html' %}
|
||||
{# M8: Price charts accordion — shown when budget mode was enabled and prices loaded #}
|
||||
{% if (price_category_chart and price_category_chart.total > 0) or price_histogram_chart %}
|
||||
<details class="analytics-accordion mt-4" id="price-charts-accordion">
|
||||
<summary class="combo-summary">
|
||||
<span>Price Breakdown</span>
|
||||
<span class="muted text-xs font-normal ml-2">spend by category & distribution</span>
|
||||
</summary>
|
||||
<div class="analytics-content mt-3">
|
||||
{% if price_category_chart and price_category_chart.total > 0 %}
|
||||
<div class="price-cat-section">
|
||||
<div class="price-cat-heading">Spend by Category — ${{ '%.2f'|format(price_category_chart.total) }} total</div>
|
||||
<div class="price-cat-bar" title="Total: ${{ '%.2f'|format(price_category_chart.total) }}">
|
||||
{% for cat in price_category_chart.order %}
|
||||
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||
{% if cat_total > 0 %}
|
||||
{% set pct = (cat_total * 100 / price_category_chart.total) | round(1) %}
|
||||
<div class="price-cat-seg"
|
||||
style="width:{{ pct }}%; background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"
|
||||
title="{{ cat }}: ${{ '%.2f'|format(cat_total) }} ({{ pct }}%)"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="price-cat-legend">
|
||||
{% for cat in price_category_chart.order %}
|
||||
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||
{% if cat_total > 0 %}
|
||||
<span class="price-cat-legend-item">
|
||||
<span class="price-cat-swatch" style="background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"></span>
|
||||
{{ cat }} ${{ '%.2f'|format(cat_total) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if price_histogram_chart %}
|
||||
<div class="price-hist-section">
|
||||
<div class="price-hist-heading">Price Distribution</div>
|
||||
<div class="price-hist-bars">
|
||||
{% for bin in price_histogram_chart %}
|
||||
<div class="price-hist-column"
|
||||
data-type="hist"
|
||||
data-range="${{ '%.2f'|format(bin.range_min) }}–${{ '%.2f'|format(bin.range_max) }}"
|
||||
data-val="{{ bin.count }}"
|
||||
data-cards="{% for c in bin.cards %}{{ c.name }}|{{ '%.2f'|format(c.price) }}{% if not loop.last %} • {% endif %}{% endfor %}">
|
||||
<div class="price-hist-bar" style="height:{{ bin.pct | default(0) }}%; background:{{ bin.color }};"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="price-hist-xlabels">
|
||||
{% for bin in price_histogram_chart %}
|
||||
<div class="price-hist-xlabel">{{ bin.x_label }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if locked_cards is defined and locked_cards %}
|
||||
|
|
@ -238,7 +303,8 @@
|
|||
<!-- Last action chip (oob-updated) -->
|
||||
<div id="last-action" aria-live="polite" class="my-1 last-action"></div>
|
||||
|
||||
<!-- Filters toolbar -->
|
||||
{% if not (status and status.startswith('Build complete')) %}
|
||||
<!-- Filters toolbar (only during active build stages) -->
|
||||
<div class="cards-toolbar">
|
||||
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
||||
<select name="filter_owned" data-pref="cards:owned">
|
||||
|
|
@ -267,21 +333,37 @@
|
|||
<span class="chip" data-chip-clear>Clear</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<!-- Minimal controls at build complete -->
|
||||
<div class="build-controls">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||
</form>
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
|
||||
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
|
||||
</form>
|
||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Sticky build controls on mobile -->
|
||||
<div class="build-controls">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||
</form>
|
||||
{% if not (status and status.startswith('Build complete')) %}
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Continue</button>
|
||||
<button type="submit" class="btn-continue" data-action="continue" {% if gated %}disabled{% endif %}>Continue</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-rerun" data-action="rerun" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Rerun Stage</button>
|
||||
<button type="submit" class="btn-rerun" data-action="rerun" {% if gated %}disabled{% endif %}>Rerun Stage</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span class="sep"></span>
|
||||
<div class="replace-toggle" role="group" aria-label="Replace toggle">
|
||||
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" class="inline-form">
|
||||
|
|
@ -306,6 +388,7 @@
|
|||
</label>
|
||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if added_cards is not none %}
|
||||
{% if history is defined and history %}
|
||||
|
|
@ -333,7 +416,9 @@
|
|||
<span><span class="ownership-badge">✔</span> Owned</span>
|
||||
<span><span class="ownership-badge">✖</span> Not owned</span>
|
||||
</div>
|
||||
|
||||
{% if stale_prices_global is defined and stale_prices_global %}
|
||||
<div class="stale-banner">⏱ Prices shown may be more than 24 hours old. Refresh price data on the Setup page if you need current values.</div>
|
||||
{% endif %}
|
||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
{% set groups = added_cards|groupby('sub_role') %}
|
||||
{% for g in groups %}
|
||||
|
|
@ -356,13 +441,17 @@
|
|||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
|
||||
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}"
|
||||
data-price="{{ (price_cache or {}).get(c.name.lower(), {}).get('usd') or '' }}"
|
||||
data-stale="{% if stale_prices is defined and c.name|lower in stale_prices %}1{% else %}0{% endif %}">
|
||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||
sizes="160px" />
|
||||
</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div>
|
||||
{% if stale_prices is defined and c.name|lower in stale_prices %}<div class="stale-price-indicator" title="Price may be outdated (>24h)">⏱</div>{% endif %}
|
||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
|
|
@ -402,13 +491,17 @@
|
|||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% endif %}"
|
||||
data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}"
|
||||
data-price="{{ (price_cache or {}).get(c.name.lower(), {}).get('usd') or '' }}"
|
||||
data-stale="{% if stale_prices is defined and c.name|lower in stale_prices %}1{% else %}0{% endif %}">
|
||||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||
sizes="160px" />
|
||||
</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div>
|
||||
{% if stale_prices is defined and c.name|lower in stale_prices %}<div class="stale-price-indicator" title="Price may be outdated (>24h)">⏱</div>{% endif %}
|
||||
<div class="name">{{ c.name|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||||
{% from 'partials/_macros.html' import lock_button %}
|
||||
|
|
@ -463,6 +556,9 @@
|
|||
{% set oob = False %}
|
||||
{% include "partials/include_exclude_summary.html" %}
|
||||
{% endif %}
|
||||
{% if budget_config and budget_config.total %}
|
||||
<script>window._budgetCfg={"total":{{ budget_config.total|float }},"card_ceiling":{{ budget_config.card_ceiling|float if budget_config.card_ceiling else 'null' }}};</script>
|
||||
{% endif %}
|
||||
<div id="deck-summary" data-summary
|
||||
hx-get="/build/step5/summary?token={{ summary_token }}"
|
||||
hx-trigger="load once, step5:refresh from:body"
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@
|
|||
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
|
||||
<span class="muted" style="font-size:12px;">Select</span>
|
||||
</label>
|
||||
<form action="/files" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ it.path }}" />
|
||||
<form action="/decks/download-csv" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
||||
</form>
|
||||
{% if it.txt_path %}
|
||||
|
|
|
|||
104
code/web/templates/decks/pickups.html
Normal file
104
code/web/templates/decks/pickups.html
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Budget Pickups{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Pickups List</h2>
|
||||
{% if commander %}
|
||||
<div class="muted" style="margin-bottom:.5rem;">Deck: <strong>{{ commander }}</strong>{% if name %} — <span class="muted text-xs">{{ name }}</span>{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="panel panel-info-warning">{{ error }}</div>
|
||||
{% elif not budget_report %}
|
||||
<div class="panel">No budget report available for this deck.</div>
|
||||
{% else %}
|
||||
|
||||
{% set bstatus = budget_report.budget_status %}
|
||||
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-bottom:.75rem;">
|
||||
{% if bstatus == 'under' %}
|
||||
Under Budget: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||
{% elif bstatus == 'soft_exceeded' %}
|
||||
Over Budget (soft): ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||
{% else %}
|
||||
Hard Cap Exceeded: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if stale_prices_global is defined and stale_prices_global %}
|
||||
<div class="stale-banner">⏱ Prices shown may be more than 24 hours old. Refresh price data on the Setup page if you need current values.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if budget_report.pickups_list %}
|
||||
<p class="muted text-sm" style="margin-bottom:.5rem;">
|
||||
Cards you don't own yet that fit the deck's themes and budget. Sorted by theme match priority.
|
||||
</p>
|
||||
<table class="pickups-table" style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Card</th>
|
||||
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Price</th>
|
||||
<th style="text-align:center; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Tier</th>
|
||||
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Priority</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for card in budget_report.pickups_list %}
|
||||
<tr>
|
||||
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||
<span class="card-name-price-hover" data-card-name="{{ card.card|e }}">{{ card.card }}</span>
|
||||
</td>
|
||||
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||
{% if card.price is not none %}
|
||||
${{ "%.2f"|format(card.price) }}{% if stale_prices is defined and card.card|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">⏱</span>{% endif %}
|
||||
{% else %}
|
||||
<span class="muted">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align:center; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">
|
||||
<span class="tier-badge tier-badge--{{ card.tier|lower }}">{{ card.tier }}</span>
|
||||
</td>
|
||||
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);" class="muted">
|
||||
{{ card.priority }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="panel muted">No pickups suggestions available — your deck may already fit the budget well.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if budget_report.over_budget_cards %}
|
||||
<h3 style="margin-top:1.25rem;">Over-Budget Cards</h3>
|
||||
<p class="muted text-sm" style="margin-bottom:.5rem;">
|
||||
Cards in your current deck that exceed the budget or per-card ceiling.
|
||||
</p>
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Card</th>
|
||||
<th style="text-align:right; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Price</th>
|
||||
<th style="text-align:left; padding:.4rem .5rem; border-bottom:1px solid var(--border,#333);">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in budget_report.over_budget_cards %}
|
||||
<tr>
|
||||
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">{{ c.card }}</td>
|
||||
<td style="text-align:right; padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);">${{ "%.2f"|format(c.price) }}{% if stale_prices is defined and c.card|lower in stale_prices %}<span class="stale-price-badge" title="Price may be outdated (>24h)">⏱</span>{% endif %}</td>
|
||||
<td style="padding:.35rem .5rem; border-bottom:1px solid var(--border-subtle,#222);" class="muted">
|
||||
{% if c.ceiling_exceeded %}Above ${{ "%.2f"|format(budget_config.card_ceiling) }} ceiling{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:1rem;">
|
||||
<a href="/decks/view?name={{ name|urlencode }}" class="btn" role="button">Back to Deck</a>
|
||||
<a href="/decks" class="btn" role="button">All Decks</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -56,8 +56,8 @@
|
|||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% if csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<form action="/decks/download-csv" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
|
@ -68,10 +68,37 @@
|
|||
</form>
|
||||
{% endif %}
|
||||
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
|
||||
{% if budget_report %}
|
||||
<a href="/decks/pickups?name={{ name|urlencode }}" class="btn" role="button" title="View cards to acquire for this budget build">Pickups List</a>
|
||||
{% endif %}
|
||||
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||
<button type="submit">Back to Finished Decks</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if budget_report %}
|
||||
{% set bstatus = budget_report.budget_status %}
|
||||
<div class="budget-badge budget-badge--{{ bstatus }}" style="margin-top:.6rem;">
|
||||
{% if bstatus == 'under' %}
|
||||
Under Budget: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||
{% elif bstatus == 'soft_exceeded' %}
|
||||
Over Budget (soft): ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||
{% else %}
|
||||
Hard Cap Exceeded: ${{ "%.2f"|format(budget_report.total_price) }} / ${{ "%.2f"|format(budget_config.total) }}
|
||||
(+${{ "%.2f"|format(budget_report.overage) }})
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if budget_report.over_budget_cards %}
|
||||
<div class="panel panel-info-warning" style="margin-top:.5rem;">
|
||||
<strong>Cards over budget:</strong>
|
||||
<ul class="muted" style="margin:.25rem 0 0 1rem; padding:0; font-size:.85em;">
|
||||
{% for c in budget_report.over_budget_cards %}
|
||||
<li>{{ c.card }} — ${{ "%.2f"|format(c.price) }}{% if c.ceiling_exceeded %} (above ${{ "%.2f"|format(budget_config.card_ceiling) }} ceiling){% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</aside>
|
||||
<div class="grow">
|
||||
{% if summary %}
|
||||
|
|
@ -99,6 +126,67 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set, combos=combos, synergies=synergies, versions=versions) | safe }}
|
||||
{# M8: Price charts accordion — placed in main area, only available on the saved deck view #}
|
||||
{% if (price_category_chart and price_category_chart.total > 0) or price_histogram_chart %}
|
||||
<section class="summary-section-lg">
|
||||
<details class="analytics-accordion" id="price-charts-accordion">
|
||||
<summary class="combo-summary">
|
||||
<span>Price Breakdown</span>
|
||||
<span class="muted text-xs font-normal ml-2">spend by category & distribution</span>
|
||||
</summary>
|
||||
<div class="analytics-content mt-3">
|
||||
{% if price_category_chart and price_category_chart.total > 0 %}
|
||||
<div class="price-cat-section">
|
||||
<div class="price-cat-heading">Spend by Category — ${{ '%.2f'|format(price_category_chart.total) }} total</div>
|
||||
<div class="price-cat-bar" title="Total: ${{ '%.2f'|format(price_category_chart.total) }}">
|
||||
{% for cat in price_category_chart.order %}
|
||||
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||
{% if cat_total > 0 %}
|
||||
{% set pct = (cat_total * 100 / price_category_chart.total) | round(1) %}
|
||||
<div class="price-cat-seg"
|
||||
style="width:{{ pct }}%; background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"
|
||||
title="{{ cat }}: ${{ '%.2f'|format(cat_total) }} ({{ pct }}%)"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="price-cat-legend">
|
||||
{% for cat in price_category_chart.order %}
|
||||
{% set cat_total = price_category_chart.totals.get(cat, 0) %}
|
||||
{% if cat_total > 0 %}
|
||||
<span class="price-cat-legend-item">
|
||||
<span class="price-cat-swatch" style="background:{{ price_category_chart.colors.get(cat, '#f59e0b') }};"></span>
|
||||
{{ cat }} ${{ '%.2f'|format(cat_total) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if price_histogram_chart %}
|
||||
<div class="price-hist-section">
|
||||
<div class="price-hist-heading">Price Distribution</div>
|
||||
<div class="price-hist-bars">
|
||||
{% for bin in price_histogram_chart %}
|
||||
<div class="price-hist-column"
|
||||
data-type="hist"
|
||||
data-range="${{ '%.2f'|format(bin.range_min) }}–${{ '%.2f'|format(bin.range_max) }}"
|
||||
data-val="{{ bin.count }}"
|
||||
data-cards="{% for c in bin.cards %}{{ c.name }}|{{ '%.2f'|format(c.price) }}{% if not loop.last %} • {% endif %}{% endfor %}">
|
||||
<div class="price-hist-bar" style="height:{{ bin.pct | default(0) }}%; background:{{ bin.color }};"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="price-hist-xlabels">
|
||||
{% for bin in price_histogram_chart %}
|
||||
<div class="price-hist-xlabel">{{ bin.x_label }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="muted">No summary available.</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
<div id="deck-summary" data-summary>
|
||||
{% if budget_config and budget_config.total %}
|
||||
<script>window._budgetCfg={"total":{{ budget_config.total|float }},"card_ceiling":{{ budget_config.card_ceiling|float if budget_config.card_ceiling else 'null' }}};</script>
|
||||
{% endif %}
|
||||
<hr class="summary-divider" />
|
||||
<h4>Deck Summary</h4>
|
||||
<section class="summary-section">
|
||||
|
|
@ -35,6 +38,9 @@
|
|||
.owned-flag { font-size:.95rem; opacity:.9; }
|
||||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
{% if budget_config and budget_config.total %}
|
||||
<div id="budget-summary-bar" class="budget-price-bar" aria-live="polite">Loading deck cost...</div>
|
||||
{% endif %}
|
||||
{% for t in tb.order %}
|
||||
<div class="summary-type-heading">
|
||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||
|
|
@ -46,7 +52,7 @@
|
|||
@media (max-width: 1199px) {
|
||||
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
}
|
||||
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) auto 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
||||
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) auto auto 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
||||
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
||||
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
|
@ -75,6 +81,7 @@
|
|||
<span class="count">{{ cnt }}</span>
|
||||
<span class="times">x</span>
|
||||
<span class="name dfc-anchor" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if c.metadata_tags %} data-metadata-tags="{{ (c.metadata_tags|map('trim')|join(', ')) }}"{% endif %}{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}>{{ c.name }}</span>
|
||||
<span class="card-price-inline" data-price-for="{{ c.name }}"></span>
|
||||
<span class="flip-slot" aria-hidden="true">
|
||||
{% if c.dfc_land %}
|
||||
<span class="dfc-land-chip {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC land{% if c.dfc_adds_extra_land %} +1{% endif %}</span>
|
||||
|
|
@ -114,8 +121,7 @@
|
|||
<img class="card-thumb" loading="lazy" decoding="async" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|map('trim')|join(', ')) if c.tags else '' }}"{% if overlaps %} data-overlaps="{{ overlaps|join(', ') }}"{% endif %}
|
||||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||||
sizes="(max-width: 1200px) 160px, 240px" />
|
||||
<div class="count-badge">{{ cnt }}x</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="count-badge">{{ cnt }}x</div> <div class="card-price-overlay" data-price-for="{{ c.name }}" aria-hidden="true"></div> <div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
{% if c.dfc_land %}
|
||||
<div class="dfc-thumb-badge {% if c.dfc_adds_extra_land %}extra{% else %}counts{% endif %}" title="{{ c.dfc_note or 'Modal double-faced land' }}">DFC{% if c.dfc_adds_extra_land %}+1{% endif %}</div>
|
||||
{% endif %}
|
||||
|
|
@ -601,6 +607,11 @@
|
|||
} else if (t === 'curve') {
|
||||
titleSpan.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
|
||||
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
|
||||
} else if (t === 'hist') {
|
||||
var hval = el.dataset.val || '0';
|
||||
titleSpan.textContent = (el.dataset.range || '') + ' \u2014 ' + hval + ' card' + (hval !== '1' ? 's' : '');
|
||||
var pairs = (el.dataset.cards || '').split(' \u2022 ').filter(Boolean);
|
||||
listText = pairs.map(function(p){ var idx = p.lastIndexOf('|'); return idx < 0 ? p : p.slice(0, idx) + ' \u2014 $' + parseFloat(p.slice(idx+1)).toFixed(2); }).join('\n');
|
||||
} else {
|
||||
titleSpan.textContent = el.getAttribute('aria-label') || '';
|
||||
}
|
||||
|
|
@ -662,6 +673,8 @@
|
|||
var s = String(n);
|
||||
// Strip trailing " ×<num>" count suffix if present
|
||||
s = s.replace(/\s×\d+$/,'');
|
||||
// Strip trailing "|price" suffix from hist bars
|
||||
s = s.replace(/\|[\d.]+$/, '');
|
||||
return s.trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
|
@ -707,8 +720,8 @@
|
|||
}
|
||||
|
||||
function attach() {
|
||||
// Attach to SVG elements with data-type for better hover zones
|
||||
document.querySelectorAll('svg[data-type]').forEach(function(el) {
|
||||
// Attach to elements with data-type (SVG mana charts + div hist bars)
|
||||
document.querySelectorAll('[data-type]').forEach(function(el) {
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
// Don't show hover tooltip if this element is pinned
|
||||
if (pinnedEl === el) return;
|
||||
|
|
@ -719,7 +732,7 @@
|
|||
// Cross-highlight for mana curve bars -> card items
|
||||
try {
|
||||
var dataType = el.getAttribute('data-type');
|
||||
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources') {
|
||||
if (dataType === 'curve' || dataType === 'pips' || dataType === 'sources' || dataType === 'hist') {
|
||||
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
|
||||
lastType = dataType;
|
||||
// Only apply hover highlights if nothing is pinned
|
||||
|
|
@ -769,7 +782,7 @@
|
|||
document.addEventListener('click', function(e) {
|
||||
if (!pinnedEl) return;
|
||||
// Don't unpin if clicking the tooltip itself or a chart
|
||||
if (tip.contains(e.target) || e.target.closest('svg[data-type]')) return;
|
||||
if (tip.contains(e.target) || e.target.closest('[data-type]')) return;
|
||||
unpin();
|
||||
});
|
||||
|
||||
|
|
@ -825,7 +838,16 @@
|
|||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
attach();
|
||||
// On static pages (view.html, run_result.html) deck_summary.html is rendered
|
||||
// before the price-chart histogram bars in the outer template, so the inline
|
||||
// script runs mid-parse and querySelectorAll('[data-type]') would not yet see
|
||||
// those elements. Deferring to DOMContentLoaded fixes this for static pages
|
||||
// while still running immediately when injected via HTMX (readyState 'complete').
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() { attach(); });
|
||||
} else {
|
||||
attach();
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -145,6 +145,25 @@
|
|||
<span class="muted" style="align-self:center; font-size:.85rem;">(~15-20 min local, instant if cached on GitHub)</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<details style="margin-top:1.25rem;" open>
|
||||
<summary>Card Price Cache Status</summary>
|
||||
<div style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:var(--panel); border-radius:8px;">
|
||||
<div class="muted">Last updated:</div>
|
||||
<div style="margin-top:.25rem;" id="price-cache-built-at">
|
||||
{% if price_cache_built_at %}{{ price_cache_built_at }}{% else %}<span class="muted">Not yet generated — run Setup first, then refresh prices.</span>{% endif %}
|
||||
</div>
|
||||
{% if price_auto_refresh %}
|
||||
<div class="muted" style="margin-top:.5rem; font-size:.85rem;">Auto-refresh is enabled (runs daily at 01:00 UTC).</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% if not price_auto_refresh %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
{{ button('Refresh Card Prices', variant='primary', onclick='refreshPriceCache()', attrs='id="btn-refresh-prices"') }}
|
||||
<span class="muted" style="align-self:center; font-size:.85rem;">Rebuilds price data from local Scryfall bulk data (requires Setup to have run).</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
|
|
@ -620,6 +639,33 @@
|
|||
setInterval(pollSimilarityStatus, 10000); // Poll every 10s
|
||||
{% endif %}
|
||||
|
||||
window.refreshPriceCache = function(){
|
||||
var btn = document.getElementById('btn-refresh-prices');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Refreshing…'; }
|
||||
fetch('/api/price/refresh', { method: 'POST' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){
|
||||
if (btn) { btn.textContent = 'Refresh started — check logs for progress.'; }
|
||||
// Update timestamp line after a short delay to let the rebuild start
|
||||
setTimeout(function(){
|
||||
fetch('/api/price/stats')
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(s){
|
||||
var el = document.getElementById('price-cache-built-at');
|
||||
if (el && s.last_refresh) {
|
||||
var d = new Date(s.last_refresh * 1000);
|
||||
el.textContent = d.toLocaleDateString('en-US', { year:'numeric', month:'long', day:'numeric' });
|
||||
}
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Refresh Card Prices'; }
|
||||
})
|
||||
.catch(function(){ if (btn) { btn.disabled = false; btn.textContent = 'Refresh Card Prices'; } });
|
||||
}, 3000);
|
||||
})
|
||||
.catch(function(){
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Refresh failed'; setTimeout(function(){ btn.textContent = 'Refresh Card Prices'; }, 2000); }
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize image status polling
|
||||
pollImageStatus();
|
||||
setInterval(pollImageStatus, 10000); // Poll every 10s
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue