feat: add Budget Mode with price cache infrastructure and stale price warnings (#61)

This commit is contained in:
mwisnowski 2026-03-23 16:38:18 -07:00 committed by GitHub
parent 1aa8e4d7e8
commit 8643b72108
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 6976 additions and 2753 deletions

View file

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

View file

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

View file

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

View file

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

View file

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