feat: implement batch build and comparison

This commit is contained in:
matt 2025-10-20 18:29:53 -07:00
parent 1d95c5cbd0
commit f1e21873e7
20 changed files with 2691 additions and 6 deletions

View file

@ -0,0 +1,8 @@
{# Batch Build Progress Indicator - Multiple Builds Running in Parallel #}
<div id="batch-progress-container" style="min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem;">
<div hx-get="/build/batch-progress?batch_id={{ batch_id }}"
hx-trigger="load, every 1s"
hx-swap="innerHTML">
{% include "build/_batch_progress_content.html" %}
</div>
</div>

View file

@ -0,0 +1,37 @@
{# Batch Build Progress Content (inner content only, for HTMX updates) #}
<div style="text-align: center; max-width: 600px;">
<h3 style="margin-bottom: 1.5rem;">Building {{ build_count }} Decks...</h3>
<div style="margin: 2rem 0;">
<div style="font-size: 3rem; font-weight: bold; color: var(--primary);">
{{ completed }} / {{ build_count }}
</div>
<div class="muted" style="font-size: 0.9rem; margin-top: 0.5rem;">
{{ status }}
</div>
</div>
{# Progress Bar #}
<div style="width: 100%; height: 8px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden; margin: 1.5rem 0;">
<div style="height: 100%; background: linear-gradient(90deg, #3b82f6, #8b5cf6); transition: width 0.3s ease; width: {{ progress_pct }}%;"></div>
</div>
<div style="margin-top: 2rem; padding: 1rem; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px;">
<p style="margin: 0; font-size: 0.9rem; line-height: 1.6;">
<strong>What's happening?</strong><br>
We're running your deck configuration {{ build_count }} times in parallel to see how card selection varies.
Each build uses the same commander, themes, and preferences but produces different results due to randomness in card selection.
</p>
</div>
{% if has_errors %}
<div class="error" style="margin-top: 1rem; text-align: left;">
<strong>⚠️ Some builds encountered errors</strong>
<p style="font-size: 0.85rem; margin-top: 0.5rem;">{{ error_count }} of {{ build_count }} builds failed. Completed builds will still be available for comparison.</p>
</div>
{% endif %}
<p class="muted" style="font-size: 0.8rem; margin-top: 1.5rem;">
This may take {{ time_estimate|default("1-3 minutes") }} depending on number of decks, theme complexity, and color count...
</p>
</div>

View file

@ -214,11 +214,37 @@
{% endif %}
{% endif %}
{% include "build/_new_deck_skip_controls.html" %}
{% if enable_batch_build %}
<fieldset>
<legend>Build Options</legend>
<div style="display:flex; flex-direction:column; gap:0.75rem;">
<label style="display:block;">
<span>Number of decks to build</span>
<small class="muted" style="display:block; font-size:11px; margin-top:.25rem;">Run the same configuration multiple times to see variance in results</small>
</label>
{% if ideals_ui_mode == 'slider' %}
<div style="display:flex; align-items:center; gap:1rem;">
<input type="range" name="build_count" id="build_count_slider" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="flex:1;"
oninput="document.getElementById('build_count_value').textContent = this.value; updateBuildCountLabel(this.value); updateButtonState(this.value);" />
<span id="build_count_value" style="min-width:2.5rem; text-align:center; font-weight:500; font-size:1.1em;">{{ form.build_count if form and form.build_count else 1 }}</span>
</div>
<small id="build_count_label" class="muted" style="font-size:11px; text-align:center;">Build 1 deck (normal build)</small>
{% else %}
<input type="number" name="build_count" id="build_count_input" min="1" max="10" value="{{ form.build_count if form and form.build_count else 1 }}" style="width:6rem;"
oninput="updateButtonState(this.value);" />
<small class="muted" style="font-size:11px;">Enter 1 for normal build, 2-10 to compare multiple results</small>
{% endif %}
</div>
</fieldset>
{% else %}
{# Hidden input to always send build_count=1 when feature disabled #}
<input type="hidden" name="build_count" value="1" />
{% endif %}
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:space-between; margin-top:1rem;">
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
<div style="display:flex; gap:.5rem;">
<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">Create</button>
<button type="submit" class="btn-continue" id="create-btn">Create</button>
</div>
</div>
</form>
@ -248,6 +274,63 @@
}
})();
// Update build count label based on slider value
function updateBuildCountLabel(count) {
var label = document.getElementById('build_count_label');
if (!label) return;
count = parseInt(count);
if (count === 1) {
label.textContent = 'Build 1 deck (normal build)';
label.className = 'muted';
} else {
label.textContent = 'Build ' + count + ' decks and compare results';
label.className = 'muted';
label.style.color = '#60a5fa';
label.style.fontWeight = '500';
}
}
// Update button state based on build count
function updateButtonState(count) {
var quickBuildBtn = document.getElementById('quick-build-btn');
var createBtn = document.getElementById('create-btn');
count = parseInt(count);
if (count > 1) {
// Multi-build: force Quick Build, hide Create button
if (createBtn) {
createBtn.style.display = 'none';
}
if (quickBuildBtn) {
quickBuildBtn.textContent = 'Build ' + count + ' Decks';
quickBuildBtn.title = 'Build ' + count + ' decks automatically and compare results';
}
} else {
// Single build: show both buttons normally
if (createBtn) {
createBtn.style.display = '';
}
if (quickBuildBtn) {
quickBuildBtn.textContent = 'Quick Build';
quickBuildBtn.title = 'Build entire deck automatically without approval steps';
}
}
}
// Initialize label and button state on page load
(function() {
var slider = document.getElementById('build_count_slider');
var input = document.getElementById('build_count_input');
var initialValue = slider ? slider.value : (input ? input.value : 1);
if (slider) {
updateBuildCountLabel(initialValue);
}
updateButtonState(initialValue);
})();
// Utility function for parsing card lists
function parseCardList(content) {
const newlineRegex = /\r?\n/;

View file

@ -0,0 +1,111 @@
{# Synergy Deck Preview - Shows the optimized "best-of" deck from batch builds #}
<div style="padding: 2rem; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,.3);">
<h2 style="margin-bottom: 1rem; color: var(--primary);">✨ Synergy Deck Preview</h2>
<div class="muted" style="margin-bottom: 1.5rem; font-size: 0.9rem;">
This deck is built from the most synergistic cards across all {{ total_builds }} builds, scored by frequency, EDHREC rank, and theme alignment.
</div>
{# Summary Stats #}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
<div style="font-size: 1.8rem; font-weight: bold; color: var(--primary);">{{ synergy_deck.total_cards }}</div>
<div class="muted" style="font-size: 0.85rem;">Total Cards</div>
</div>
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
<div style="font-size: 1.8rem; font-weight: bold; color: #10b981;">{{ (synergy_deck.avg_frequency * 100) | round(1) }}%</div>
<div class="muted" style="font-size: 0.85rem;">Avg Frequency</div>
</div>
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
<div style="font-size: 1.8rem; font-weight: bold; color: #3b82f6;">{{ synergy_deck.avg_score }}</div>
<div class="muted" style="font-size: 0.85rem;">Avg Synergy Score</div>
</div>
<div style="padding: 1rem; text-align: center; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
<div style="font-size: 1.8rem; font-weight: bold; color: #8b5cf6;">{{ synergy_deck.high_frequency_count }}</div>
<div class="muted" style="font-size: 0.85rem;">High-Frequency Cards (80%+)</div>
</div>
</div>
{# Cards by Category #}
<div style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 1rem;">Cards by Type</h3>
<div style="display: grid; gap: 1.5rem;">
{% for category, cards in synergy_deck.cards_by_category.items() %}
<details>
<summary style="cursor: pointer; font-size: 1rem; font-weight: 500; margin-bottom: 0.75rem; color: var(--primary);">
{{ category }} ({{ cards | sum(attribute='count') }})
</summary>
<div style="padding: 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="border-bottom: 2px solid var(--border);">
<th style="text-align: left; padding: 0.5rem;">Card Name</th>
<th style="text-align: center; padding: 0.5rem;">Frequency</th>
<th style="text-align: center; padding: 0.5rem;">Synergy Score</th>
<th style="text-align: center; padding: 0.5rem;">Appears In</th>
</tr>
</thead>
<tbody>
{% for card in cards %}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 0.5rem;">
<div>
{% if card.count and card.count > 1 %}
<span style="font-weight: 600; color: var(--primary);">{{ card.count }}x</span>
{% endif %}
<span data-card-name="{{ card.name }}" style="cursor: help;">{{ card.name }}</span>
</div>
{% if card.type_line %}
<div class="muted" style="font-size: 0.8rem; margin-top: 0.15rem;">{{ card.type_line }}</div>
{% endif %}
{% if card.role %}
<div class="muted" style="font-size: 0.75rem; margin-top: 0.1rem; font-style: italic;">{{ card.role }}</div>
{% endif %}
</td>
<td style="text-align: center; padding: 0.5rem;">
<span style="
font-weight: 500;
color: {% if card.frequency >= 0.8 %}#10b981{% elif card.frequency >= 0.5 %}#3b82f6{% else %}#6b7280{% endif %};
">
{{ (card.frequency * 100) | round(0) | int }}%
</span>
</td>
<td style="text-align: center; padding: 0.5rem;">
<span style="
font-weight: 500;
color: {% if card.synergy_score >= 70 %}#8b5cf6{% elif card.synergy_score >= 50 %}#3b82f6{% else %}#6b7280{% endif %};
">
{{ card.synergy_score }}
</span>
</td>
<td style="text-align: center; padding: 0.5rem; color: #6b7280;">
{{ card.appearance_count }}/{{ total_builds }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endfor %}
</div>
</div>
{# Actions #}
<div style="display: flex; gap: 1rem; justify-content: center;">
<button class="btn" onclick="document.getElementById('synergy-preview').innerHTML = ''">
Close Preview
</button>
<button class="btn-continue" onclick="exportSynergyDeck('{{ batch_id }}')" id="export-synergy-btn">
Export Synergy Deck
</button>
</div>
</div>
<script>
// Attach card hover to new elements
if (window.attachCardHover) {
window.attachCardHover();
}
</script>

View file

@ -0,0 +1,221 @@
{% extends "base.html" %}
{% block title %}Compare Builds - {{ config.commander }}{% endblock %}
{% block content %}
<div class="container" style="max-width: 1400px; padding: 2rem;">
<div style="margin-bottom: 2rem;">
<h1 style="margin-bottom: 0.5rem;">Compare {{ build_count }} Builds</h1>
<div class="muted" style="font-size: 0.9rem;">
<strong>Commander:</strong> {{ config.commander }}
{% if config.tags %}
| <strong>Themes:</strong> {{ config.tags | join(", ") }}
{% endif %}
| <strong>Bracket:</strong> {{ config.bracket }}
</div>
</div>
{# Overview Stats #}
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div class="card" style="padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: var(--primary);">{{ overlap_stats.total_unique_cards }}</div>
<div class="muted" style="font-size: 0.85rem;">Unique Cards Total</div>
</div>
<div class="card" style="padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #10b981;">{{ overlap_stats.cards_in_all }}</div>
<div class="muted" style="font-size: 0.85rem;">In All Builds</div>
</div>
<div class="card" style="padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #3b82f6;">{{ overlap_stats.cards_in_most }}</div>
<div class="muted" style="font-size: 0.85rem;">In Most Builds (80%+)</div>
</div>
<div class="card" style="padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #f59e0b;">{{ overlap_stats.cards_in_some }}</div>
<div class="muted" style="font-size: 0.85rem;">In Some Builds</div>
</div>
<div class="card" style="padding: 1rem; text-align: center;">
<div style="font-size: 2rem; font-weight: bold; color: #ef4444;">{{ overlap_stats.cards_in_few }}</div>
<div class="muted" style="font-size: 0.85rem;">In Few Builds</div>
</div>
</div>
{# Most Common Cards #}
<details open style="margin-bottom: 2rem;">
<summary style="cursor: pointer; font-size: 1.1rem; font-weight: 500; margin-bottom: 1rem;">
📊 Most Common Cards
</summary>
<div class="card" style="padding: 1rem;">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 0.5rem;">
{% for card_name, count in overlap_stats.most_common[:20] %}
<div style="display: flex; justify-content: space-between; padding: 0.5rem; background: rgba(59, 130, 246, 0.1); border-radius: 4px;">
<span data-card-name="{{ card_name }}" style="cursor: help;">{{ card_name }}</span>
<span style="font-weight: 500; color: var(--primary);">{{ count }}/{{ build_count }}</span>
</div>
{% endfor %}
</div>
</div>
</details>
{# Individual Build Comparisons #}
<h2 style="margin-bottom: 1rem;">Individual Builds</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
{% for build in builds %}
<div class="card" style="padding: 1.5rem;">
<h3 style="margin-bottom: 1rem; color: var(--primary);">Build #{{ build.build_number }}</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem;">
<div>
<div class="muted" style="font-size: 0.8rem;">Total Cards</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ build.total_cards }}</div>
</div>
<div>
<div class="muted" style="font-size: 0.8rem;">Creatures</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ build.creatures }}</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; font-size: 0.85rem;">
<div>
<div class="muted">Lands</div>
<div>{{ build.lands }}</div>
</div>
<div>
<div class="muted">Artifacts</div>
<div>{{ build.artifacts }}</div>
</div>
<div>
<div class="muted">Enchantments</div>
<div>{{ build.enchantments }}</div>
</div>
<div>
<div class="muted">Instants</div>
<div>{{ build.instants }}</div>
</div>
<div>
<div class="muted">Sorceries</div>
<div>{{ build.sorceries }}</div>
</div>
<div>
<div class="muted">Planeswalkers</div>
<div>{{ build.planeswalkers }}</div>
</div>
</div>
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; font-size: 0.9rem; color: var(--primary);">
View All Cards ({{ build.total_cards }})
</summary>
<div style="margin-top: 0.75rem; max-height: 300px; overflow-y: auto; font-size: 0.85rem;">
{% for card in build.cards %}
<div style="padding: 0.25rem 0; border-bottom: 1px solid var(--border);">
<span data-card-name="{{ card.name if card is mapping else card }}" style="cursor: help;">
{{ card.name if card is mapping else card }}
</span>
</div>
{% endfor %}
</div>
</details>
</div>
{% endfor %}
</div>
{# Actions #}
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; margin-top: 2rem;">
<a href="/build" class="btn">Build New Deck</a>
<form method="POST" action="/compare/{{ batch_id }}/rebuild" style="display: inline;">
<button type="submit" class="btn" title="Run {{ build_count }} more builds with the same configuration">
🔄 Rebuild {{ build_count }}x
</button>
</form>
<button
id="build-synergy-btn"
class="btn-continue"
hx-post="/compare/{{ batch_id }}/build-synergy"
hx-target="#synergy-preview"
hx-swap="innerHTML"
>
✨ Build Synergy Deck
</button>
<form method="POST" action="/compare/{{ batch_id }}/export" style="display: inline;">
<button
type="submit"
class="btn-continue"
{% if synergy_exported %}disabled title="Individual batch files have been deleted after synergy export"{% endif %}
style="{% if synergy_exported %}opacity: 0.5; cursor: not-allowed;{% endif %}"
>
{% if synergy_exported %}Batch Files Deleted{% else %}Export All Decks as ZIP{% endif %}
</button>
</form>
</div>
{# Synergy Preview Container #}
<div id="synergy-preview" style="margin-top: 2rem;"></div>
</div>
<script>
// Global function for exporting synergy deck (needed for HTMX-loaded content)
function exportSynergyDeck(batchId) {
const btn = document.getElementById('export-synergy-btn');
if (!batchId) {
alert('Batch ID not found');
return;
}
// Show warning about deleting batch files
if (!confirm('⚠️ Warning: Exporting the synergy deck will delete all individual batch build files.\n\nThis action cannot be undone. After export, you will only be able to download the synergy deck.\n\nContinue with export?')) {
return;
}
// Disable button and show loading state
btn.disabled = true;
btn.textContent = 'Exporting...';
// Create a form and submit it to trigger download
fetch(`/compare/${batchId}/export-synergy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Export failed');
}
return response.blob();
})
.then(blob => {
// Create download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `synergy_deck_${batchId}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Update button state
btn.textContent = '✓ Exported';
btn.style.opacity = '0.6';
// Reload page to update batch export button state
setTimeout(() => {
window.location.reload();
}, 1000);
})
.catch(error => {
console.error('Export error:', error);
alert('Failed to export synergy deck. Check console for details.');
btn.disabled = false;
btn.textContent = 'Export Synergy Deck';
});
}
// Ensure card hover is attached after page load
if (window.attachCardHover) {
window.attachCardHover();
}
</script>
{% endblock %}