mtg_python_deckbuilder/code/web/templates/diagnostics/index.html

718 lines
No EOL
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block content %}
<section>
<h2>Diagnostics</h2>
<p class="muted">Use these tools to verify error handling surfaces.</p>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
<div id="envFlags" style="margin-top:.5rem"></div>
<div id="themeSuppMetrics" class="muted" style="margin-top:.5rem">Loading theme metrics…</div>
<div id="themeSummary" style="margin-top:.5rem"></div>
<div id="themeTokenStats" class="muted" style="margin-top:.5rem">Loading theme stats…</div>
<div style="margin-top:.35rem">
<button class="btn" id="diag-theme-reset">Reset theme preference</button>
</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Multi-face merge snapshot</h3>
<div class="muted" style="margin-bottom:.35rem">Pulls from <code>logs/dfc_merge_summary.json</code> to verify merge coverage.</div>
{% set colors = (merge_summary.colors if merge_summary else {}) | default({}) %}
{% if colors %}
<div class="muted" style="margin-bottom:.35rem">Last updated: {{ merge_summary.updated_at or 'unknown' }}</div>
<div style="overflow-x:auto">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="border-bottom:1px solid var(--border); text-align:left;">
<th style="padding:.35rem .25rem;">Color</th>
<th style="padding:.35rem .25rem;">Groups merged</th>
<th style="padding:.35rem .25rem;">Faces dropped</th>
<th style="padding:.35rem .25rem;">Multi-face rows</th>
<th style="padding:.35rem .25rem;">Latest entries</th>
</tr>
</thead>
<tbody>
{% for item in colors|dictsort %}
{% set color = item[0] %}
{% set payload = item[1] | default({}) %}
<tr style="border-bottom:1px solid rgba(148,163,184,0.2);">
<td style="padding:.35rem .25rem; font-weight:600;">{{ color|title }}</td>
<td style="padding:.35rem .25rem;">{{ payload.group_count or 0 }}</td>
<td style="padding:.35rem .25rem;">{{ payload.faces_dropped or 0 }}</td>
<td style="padding:.35rem .25rem;">{{ payload.multi_face_rows or 0 }}</td>
<td style="padding:.35rem .25rem;">
{% set entries = (payload.entries | default([])) %}
{% if entries %}
<details>
<summary style="cursor:pointer;">{{ entries|length }} recorded</summary>
{% set preview = entries[:5] %}
<ul style="margin:.35rem 0 0 .75rem; padding:0; list-style:disc; max-height:180px; overflow:auto;">
{% for entry in preview %}
<li style="margin-bottom:.25rem;">
<strong>{{ entry.name }}</strong> — {{ entry.total_faces }} faces (dropped {{ entry.dropped_faces }})
</li>
{% endfor %}
{% if entries|length > preview|length %}
<li style="font-size:11px; opacity:.75;">… {{ entries|length - preview|length }} more entries</li>
{% endif %}
</ul>
</details>
{% else %}
<span class="muted">No groups recorded</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="muted">No merge summary has been recorded. Run the tagger with multi-face merging enabled.</div>
{% endif %}
<div id="dfcMetrics" class="muted" style="margin-top:.5rem;">Loading MDFC metrics…</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Dual-Commander diagnostics</h3>
<div class="muted" style="margin-bottom:.35rem;">Latest partner, partner-with, doctor, and background pairings with color sources.</div>
<div id="partnerMetricsSummary" class="muted">Loading partner metrics…</div>
<div id="partnerMetricsModes" class="muted" style="margin-top:.5rem;"></div>
<div id="partnerColorSources" style="margin-top:.5rem;"></div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Performance (local)</h3>
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
<div style="display:flex; gap:1rem; flex-wrap:wrap">
<div><strong>Scroll FPS:</strong> <span id="perf-fps"></span></div>
<div><strong>Visible tiles:</strong> <span id="perf-visible"></span></div>
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
</div>
</div>
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">Combos & Synergies (ad-hoc)</h3>
<div class="muted" style="margin-bottom:.35rem">Paste card names (one per line) and detect two-card combos and synergies using current lists.</div>
<textarea id="diag-combos-input" rows="6" style="width:100%; resize:vertical; font-family: var(--mono);"></textarea>
<div style="margin-top:.5rem; display:flex; gap:.5rem; align-items:center">
<button class="btn" id="diag-combos-run">Detect</button>
<small class="muted">Runs in diagnostics mode only.</small>
</div>
<pre id="diag-combos-out" style="margin-top:.5rem; white-space:pre-wrap"></pre>
</div>
{% if enable_pwa %}
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">PWA status</h3>
<div id="pwaStatus" class="muted">Checking…</div>
</div>
{% endif %}
<div class="card" style="background: var(--panel); border:1px solid var(--border); border-radius:10px; padding:.75rem;">
<h3 style="margin-top:0">Error triggers</h3>
<div class="row" style="display:flex; gap:.5rem; align-items:center">
<button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
<button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button>
<small class="muted">You should see a toast and an inline banner with Request-ID.</small>
</div>
</div>
{% if show_logs %}
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
{% endif %}
</section>
<script>
(function(){
var el = document.getElementById('sysSummary');
function render(data){
if (!el) return;
try {
var v = (data && data.version) || 'dev';
var up = (data && data.uptime_seconds) || 0;
var st = (data && data.server_time_utc) || '';
var flags = (data && data.flags) || {};
el.innerHTML = '<div><strong>Version:</strong> '+String(v)+'</div>'+
(st ? '<div><strong>Server time (UTC):</strong> '+String(st)+'</div>' : '')+
'<div><strong>Uptime:</strong> '+String(up)+'s</div>'+
'<div><strong>Flags:</strong> '
+ 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0')
+ ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0')
+ ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0')
+ ', SHOW_COMMANDERS='+ (flags.SHOW_COMMANDERS? '1':'0')
+ ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0')
+ ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0')
+ ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '')
+ ', RANDOM_TIMEOUT_MS='+ String(flags.RANDOM_TIMEOUT_MS ?? '')
+ '</div>';
} catch(_){ el.textContent = 'Unavailable'; }
}
function load(){
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
}
load();
// Environment flags card
(function(){
var target = document.getElementById('envFlags');
if (!target) return;
function renderEnv(data){
if (!data || !data.flags) { target.textContent = 'Flags unavailable'; return; }
var f = data.flags;
function as01(v){ return (v ? '1' : '0'); }
var lines = [];
lines.push('<div><strong>Homepage & UI:</strong> '
+ 'SHOW_SETUP=' + as01(f.SHOW_SETUP)
+ ', SHOW_LOGS=' + as01(f.SHOW_LOGS)
+ ', SHOW_DIAGNOSTICS=' + as01(f.SHOW_DIAGNOSTICS)
+ ', SHOW_COMMANDERS=' + as01(f.SHOW_COMMANDERS)
+ ', ENABLE_THEMES=' + as01(f.ENABLE_THEMES)
+ ', ENABLE_CUSTOM_THEMES=' + as01(f.ENABLE_CUSTOM_THEMES)
+ ', ALLOW_MUST_HAVES=' + as01(f.ALLOW_MUST_HAVES)
+ ', THEME=' + String(f.DEFAULT_THEME || '')
+ ', THEME_MATCH_MODE=' + String(f.THEME_MATCH_MODE || '')
+ ', USER_THEME_LIMIT=' + String(f.USER_THEME_LIMIT || '')
+ '</div>');
lines.push('<div><strong>Random:</strong> '
+ 'RANDOM_MODES=' + as01(f.RANDOM_MODES)
+ ', RANDOM_UI=' + as01(f.RANDOM_UI)
+ ', RANDOM_MAX_ATTEMPTS=' + String(f.RANDOM_MAX_ATTEMPTS || '')
+ ', RANDOM_TIMEOUT_MS=' + String(f.RANDOM_TIMEOUT_MS || '')
+ ', RANDOM_REROLL_THROTTLE_MS=' + String(f.RANDOM_REROLL_THROTTLE_MS || '')
+ ', RANDOM_TELEMETRY=' + as01(f.RANDOM_TELEMETRY)
+ ', RANDOM_STRUCTURED_LOGS=' + as01(f.RANDOM_STRUCTURED_LOGS)
+ '</div>');
lines.push('<div><strong>Rate limiting (random):</strong> '
+ 'RATE_LIMIT_ENABLED=' + as01(f.RATE_LIMIT_ENABLED)
+ ', WINDOW_S=' + String(f.RATE_LIMIT_WINDOW_S || '')
+ ', RANDOM=' + String(f.RANDOM_RATE_LIMIT_RANDOM || '')
+ ', BUILD=' + String(f.RANDOM_RATE_LIMIT_BUILD || '')
+ ', SUGGEST=' + String(f.RANDOM_RATE_LIMIT_SUGGEST || '')
+ '</div>');
target.innerHTML = lines.join('');
}
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(renderEnv).catch(function(){ target.textContent='Flags unavailable'; }); } catch(_){ target.textContent='Flags unavailable'; }
})();
var themeSuppEl = document.getElementById('themeSuppMetrics');
function renderThemeSupp(payload){
if (!themeSuppEl) return;
try {
if (!payload || payload.ok !== true) {
themeSuppEl.textContent = 'Theme metrics unavailable';
return;
}
var metrics = payload.metrics || {};
var total = metrics.total_builds != null ? Number(metrics.total_builds) : 0;
if (!total) {
themeSuppEl.textContent = 'No deck builds recorded yet.';
return;
}
var withUser = metrics.with_user_themes != null ? Number(metrics.with_user_themes) : 0;
var share = metrics.user_theme_share != null ? Number(metrics.user_theme_share) : 0;
var sharePct = !Number.isNaN(share) ? (share * 100).toFixed(1) + '%' : '0%';
var summary = metrics.last_summary || {};
var commander = Array.isArray(summary.commanderThemes) ? summary.commanderThemes : [];
var user = Array.isArray(summary.userThemes) ? summary.userThemes : [];
var merged = Array.isArray(summary.mergedThemes) ? summary.mergedThemes : [];
var unresolvedCount = summary.unresolvedCount != null ? Number(summary.unresolvedCount) : 0;
var unresolved = Array.isArray(summary.unresolved) ? summary.unresolved : [];
var mode = summary.mode || 'AND';
var weight = summary.weight != null ? Number(summary.weight) : 1;
var updated = metrics.last_updated || '';
var topUser = Array.isArray(metrics.top_user_themes) ? metrics.top_user_themes : [];
function joinList(arr){
if (!arr || !arr.length) return '—';
return arr.join(', ');
}
var html = '';
html += '<div><strong>Total builds:</strong> ' + String(total) + ' (user themes ' + String(withUser) + '\u00a0| ' + sharePct + ')</div>';
if (updated) {
html += '<div style="font-size:11px;">Last updated: ' + String(updated) + '</div>';
}
html += '<div><strong>Commander themes:</strong> ' + joinList(commander) + '</div>';
html += '<div><strong>User themes:</strong> ' + joinList(user) + '</div>';
html += '<div><strong>Merged:</strong> ' + joinList(merged) + '</div>';
var unresolvedLabel = '0';
if (unresolvedCount > 0) {
unresolvedLabel = String(unresolvedCount) + ' (' + joinList(unresolved) + ')';
} else {
unresolvedLabel = '0';
}
html += '<div><strong>Unresolved:</strong> ' + unresolvedLabel + '</div>';
html += '<div style="font-size:11px;">Mode ' + String(mode) + ' · Weight ' + weight.toFixed(2) + '</div>';
if (topUser.length) {
var topLine = topUser.slice(0, 5).map(function(item){
if (!item) return '';
var t = item.theme != null ? String(item.theme) : '';
var c = item.count != null ? String(item.count) : '0';
return t + ' (' + c + ')';
}).filter(Boolean);
if (topLine.length) {
html += '<div style="font-size:11px; opacity:0.75;">Top user themes: ' + topLine.join(', ') + '</div>';
}
}
themeSuppEl.innerHTML = html;
} catch (_){
themeSuppEl.textContent = 'Theme metrics unavailable';
}
}
function loadThemeSupp(){
if (!themeSuppEl) return;
themeSuppEl.textContent = 'Loading theme metrics…';
fetch('/status/theme_metrics', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404) {
themeSuppEl.textContent = 'Diagnostics disabled (metrics unavailable)';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderThemeSupp(data); })
.catch(function(){ themeSuppEl.textContent = 'Theme metrics unavailable'; });
}
loadThemeSupp();
var tokenEl = document.getElementById('themeTokenStats');
function renderTokens(payload){
if (!tokenEl) return;
try {
if (!payload || payload.ok !== true) {
tokenEl.textContent = 'Theme stats unavailable';
return;
}
var stats = payload.stats || {};
var top = Array.isArray(stats.top_tokens) ? stats.top_tokens.slice(0, 5) : [];
var html = '';
var commanders = (stats && stats.commanders != null) ? stats.commanders : '0';
var withTags = (stats && stats.with_tags != null) ? stats.with_tags : '0';
var uniqueTokens = (stats && stats.unique_tokens != null) ? stats.unique_tokens : '0';
var assignments = (stats && stats.total_assignments != null) ? stats.total_assignments : '0';
var avgTokens = (stats && stats.avg_tokens_per_commander != null) ? stats.avg_tokens_per_commander : '0';
var medianTokens = (stats && stats.median_tokens_per_commander != null) ? stats.median_tokens_per_commander : '0';
html += '<div><strong>Commanders indexed:</strong> ' + String(commanders) + ' (' + String(withTags) + ' with tags)</div>';
html += '<div><strong>Theme tokens:</strong> ' + String(uniqueTokens) + ' unique; ' + String(assignments) + ' assignments</div>';
html += '<div><strong>Tokens per commander:</strong> avg ' + String(avgTokens) + ', median ' + String(medianTokens) + '</div>';
if (top.length) {
var parts = [];
top.forEach(function(item){
parts.push(String(item.token) + ' (' + String(item.count) + ')');
});
html += '<div><strong>Top tokens:</strong> ' + parts.join(', ') + '</div>';
}
var pool = stats.random_pool || {};
if (pool && typeof pool.size !== 'undefined'){
var coveragePct = null;
if (pool.coverage_ratio != null){
var cov = Number(pool.coverage_ratio);
if (!Number.isNaN(cov)){ coveragePct = (cov * 100).toFixed(1); }
}
html += '<div style="margin-top:0.35rem;"><strong>Curated random pool:</strong> ' + String(pool.size) + ' tokens';
if (coveragePct !== null){ html += ' (' + coveragePct + '% of catalog tokens)'; }
html += '</div>';
var rules = pool.rules || {};
var threshold = rules.overrepresented_share_threshold;
if (threshold != null){
var thrPct = Number(threshold);
if (!Number.isNaN(thrPct)){ html += '<div style="font-size:11px;">Over-represented threshold: ≥ ' + (thrPct * 100).toFixed(1) + '% of commanders</div>'; }
}
var excludedCounts = pool.excluded_counts || {};
var reasonKeys = Object.keys(excludedCounts);
if (reasonKeys.length){
var badges = reasonKeys.map(function(reason){
return reason + ' (' + excludedCounts[reason] + ')';
});
html += '<div style="font-size:11px;">Exclusions: ' + badges.join(', ') + '</div>';
}
var samples = pool.excluded_samples || {};
var sampleKeys = Object.keys(samples);
if (sampleKeys.length){
var sampleLines = [];
sampleKeys.slice(0, 3).forEach(function(reason){
var tokens = samples[reason] || [];
var sampleTokens = (tokens || []).slice(0, 3);
var remainder = Math.max((tokens || []).length - sampleTokens.length, 0);
var tokenLabel = sampleTokens.join(', ');
if (remainder > 0){ tokenLabel += ' +' + remainder; }
sampleLines.push(reason + ': ' + tokenLabel);
});
html += '<div style="font-size:11px; opacity:0.75;">Samples → ' + sampleLines.join(' | ') + '</div>';
}
var manualDetail = pool.manual_exclusion_detail || {};
var manualKeys = Object.keys(manualDetail);
if (manualKeys.length){
var manualSamples = manualKeys.slice(0, 3).map(function(token){
var info = manualDetail[token] || {};
var label = info.display || token;
var cat = info.category ? (' [' + info.category + ']') : '';
return label + cat;
});
var manualRemainder = Math.max(manualKeys.length - manualSamples.length, 0);
var manualLine = manualSamples.join(', ');
if (manualRemainder > 0){ manualLine += ' +' + manualRemainder; }
html += '<div style="font-size:11px;">Manual exclusions: ' + manualLine + '</div>';
}
var manualGroups = Array.isArray(rules.manual_exclusions) ? rules.manual_exclusions : [];
if (manualGroups.length){
var categoryList = manualGroups.map(function(group){ return group.category || 'manual'; });
html += '<div style="font-size:11px; opacity:0.75;">Manual categories: ' + categoryList.join(', ') + '</div>';
}
}
var telemetry = stats.index_telemetry || {};
if (telemetry && typeof telemetry.token_count !== 'undefined'){
var hitRate = telemetry.hit_rate != null ? Number(telemetry.hit_rate) : null;
var hitPct = (hitRate !== null && !Number.isNaN(hitRate)) ? (hitRate * 100).toFixed(1) : null;
var teleLine = '<div style="font-size:11px; margin-top:0.25rem;">Tag index: ' + String(telemetry.token_count || 0) + ' tokens · lookups ' + String(telemetry.lookups || 0);
if (hitPct !== null){ teleLine += ' · hit rate ' + hitPct + '%'; }
if (telemetry.substring_checks){ teleLine += ' · substring checks ' + String(telemetry.substring_checks || 0); }
teleLine += '</div>';
html += teleLine;
}
tokenEl.innerHTML = html;
} catch(_){
tokenEl.textContent = 'Theme stats unavailable';
}
}
function loadTokenStats(){
if (!tokenEl) return;
tokenEl.textContent = 'Loading theme stats…';
fetch('/status/random_theme_stats', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404) {
tokenEl.textContent = 'Diagnostics disabled (stats unavailable)';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderTokens(data); })
.catch(function(){ tokenEl.textContent = 'Theme stats unavailable'; });
}
loadTokenStats();
var dfcMetricsEl = document.getElementById('dfcMetrics');
function renderDfcMetrics(payload){
if (!dfcMetricsEl) return;
try {
if (!payload || payload.ok !== true) {
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
return;
}
var metrics = payload.metrics || {};
var html = '';
html += '<div><strong>Deck summaries observed:</strong> ' + String(metrics.total_builds || 0) + '</div>';
var withDfc = Number(metrics.builds_with_mdfc || 0);
var share = metrics.build_share != null ? Number(metrics.build_share) : null;
if (!Number.isNaN(share) && share !== null) {
share = (share * 100).toFixed(1);
} else {
share = null;
}
html += '<div><strong>With MDFCs:</strong> ' + String(withDfc);
if (share !== null) {
html += ' (' + share + '%)';
}
html += '</div>';
var totalLands = Number(metrics.total_mdfc_lands || 0);
var avg = metrics.avg_mdfc_lands != null ? Number(metrics.avg_mdfc_lands) : null;
html += '<div><strong>Total MDFC lands:</strong> ' + String(totalLands);
if (avg !== null && !Number.isNaN(avg)) {
html += ' (avg ' + avg.toFixed(2) + ')';
}
html += '</div>';
var top = metrics.top_cards || {};
var topKeys = Object.keys(top);
if (topKeys.length) {
var items = topKeys.slice(0, 5).map(function(name){
return name + ' (' + String(top[name]) + ')';
});
html += '<div style="font-size:11px;">Top MDFC sources: ' + items.join(', ') + '</div>';
}
var last = metrics.last_summary || {};
if (typeof last.dfc_lands !== 'undefined') {
html += '<div style="font-size:11px; margin-top:0.25rem;">Last summary: ' + String(last.dfc_lands || 0) + ' MDFC lands · total with MDFCs ' + String(last.with_dfc || 0) + '</div>';
}
if (metrics.last_updated) {
html += '<div style="font-size:11px;">Updated: ' + String(metrics.last_updated) + '</div>';
}
dfcMetricsEl.innerHTML = html;
} catch (_){
dfcMetricsEl.textContent = 'MDFC metrics unavailable';
}
}
function loadDfcMetrics(){
if (!dfcMetricsEl) return;
dfcMetricsEl.textContent = 'Loading MDFC metrics…';
fetch('/status/dfc_metrics', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404) {
dfcMetricsEl.textContent = 'Diagnostics disabled (metrics unavailable)';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderDfcMetrics(data); })
.catch(function(){ dfcMetricsEl.textContent = 'MDFC metrics unavailable'; });
}
loadDfcMetrics();
var partnerSummaryEl = document.getElementById('partnerMetricsSummary');
var partnerModesEl = document.getElementById('partnerMetricsModes');
var partnerSourcesEl = document.getElementById('partnerColorSources');
function escapeHtml(str){
return String(str == null ? '' : str).replace(/[&<>"']/g, function(ch){
return ({"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;"}[ch]) || ch;
});
}
function labelForPartnerRole(role){
var key = role == null ? '' : String(role).toLowerCase();
var map = {
'primary': 'Primary',
'partner': 'Partner commander',
'partner_with': 'Partner With',
'background': 'Background',
'companion': "Doctor's Companion",
'doctor_companion': "Doctor's Companion",
'doctor': 'Doctor',
'secondary': 'Secondary',
};
if (map[key]) return map[key];
if (!key) return '';
return key.replace(/_/g, ' ').replace(/\b\w/g, function(ch){ return ch.toUpperCase(); });
}
function labelForPartnerMode(mode){
var key = mode == null ? 'none' : String(mode).toLowerCase();
var map = {
'none': 'Single commander',
'partner': 'Partner',
'partner_with': 'Partner With',
'background': 'Choose a Background',
'doctor_companion': "Doctor & Companion",
'doctor': 'Doctor',
};
return map[key] || labelForPartnerRole(key) || key;
}
function buildModeCountsHtml(modeCounts, total){
var html = '<div><strong>Total pairings observed:</strong> ' + String(total || 0) + '</div>';
var keys = Object.keys(modeCounts || {}).filter(function(k){ return Number(modeCounts[k] || 0) > 0; });
if (keys.length){
var parts = keys.sort().map(function(k){
return labelForPartnerMode(k) + ': ' + String(modeCounts[k]);
});
html += '<div style="font-size:12px;">Mode breakdown: ' + parts.join(' · ') + '</div>';
}
return html;
}
function renderPartnerMetrics(payload){
if (!partnerSummaryEl) return;
try{
if (!payload || payload.ok !== true){
partnerSummaryEl.textContent = 'Partner metrics unavailable';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
return;
}
var metrics = payload.metrics || {};
var total = Number(metrics.total_pairs || 0);
var modeCounts = metrics.mode_counts || {};
var last = metrics.last_summary || null;
var updated = metrics.last_updated || '';
if (!total || !last){
partnerSummaryEl.textContent = 'No partner/background builds recorded yet.';
if (partnerModesEl) partnerModesEl.innerHTML = buildModeCountsHtml(modeCounts, total);
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
return;
}
var primary = last.primary != null ? String(last.primary) : '';
var secondary = last.secondary != null ? String(last.secondary) : '';
if (!primary && Array.isArray(last.names) && last.names.length){ primary = String(last.names[0] || ''); }
if (!secondary && Array.isArray(last.names) && last.names.length > 1){ secondary = String(last.names[1] || ''); }
var header = '<div><strong>Latest pairing:</strong> ' + escapeHtml(primary || '—');
if (secondary){ header += ' + ' + escapeHtml(secondary); }
header += '</div>';
header += '<div><strong>Mode:</strong> ' + escapeHtml(labelForPartnerMode(last.partner_mode)) + '</div>';
var colorLabel = last.color_label != null ? String(last.color_label) : '';
var colorCode = last.color_code != null ? String(last.color_code) : '';
var colors = Array.isArray(last.color_identity) ? last.color_identity.filter(Boolean).map(String).join(' / ') : '';
if (colorLabel || colorCode || colors){
var labelText = colorLabel || colors || colorCode;
var extra = (!colorLabel && colorCode && colorCode !== labelText) ? ' (' + escapeHtml(colorCode) + ')' : '';
if (colorLabel && colorCode && colorLabel.indexOf(colorCode) === -1){ extra = ' (' + escapeHtml(colorCode) + ')'; }
header += '<div><strong>Colors:</strong> ' + escapeHtml(labelText) + extra + '</div>';
}
if (updated){
header += '<div style="font-size:11px; opacity:0.75;">Last updated: ' + escapeHtml(updated) + '</div>';
}
partnerSummaryEl.innerHTML = header;
if (partnerModesEl){
partnerModesEl.innerHTML = buildModeCountsHtml(modeCounts, total);
}
if (partnerSourcesEl){
var sources = Array.isArray(last.color_sources) ? last.color_sources : [];
if (!sources.length){
partnerSourcesEl.innerHTML = '<div class="muted">No color source breakdown recorded.</div>';
} else {
var html = '<div><strong>Color sources</strong></div>';
html += '<ul style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.25rem;">';
sources.forEach(function(entry){
var color = entry && entry.color != null ? String(entry.color) : '?';
var providers = Array.isArray(entry && entry.providers) ? entry.providers : [];
var providerParts = providers.map(function(provider){
var name = provider && provider.name != null ? String(provider.name) : 'Unknown';
var roleLabel = labelForPartnerRole(provider && provider.role);
if (roleLabel){
return escapeHtml(name) + ' [' + escapeHtml(roleLabel) + ']';
}
return escapeHtml(name);
});
if (!providerParts.length){ providerParts.push('—'); }
html += '<li class="muted"><span class="chip" style="display:inline-flex; align-items:center; gap:.25rem;"><span class="dot" style="background: var(--border);"></span> ' + escapeHtml(color) + '</span> ' + providerParts.join(', ') + '</li>';
});
html += '</ul>';
var delta = last.color_delta || {};
try{
var deltaParts = [];
var added = Array.isArray(delta.added) ? delta.added.filter(Boolean) : [];
var removed = Array.isArray(delta.removed) ? delta.removed.filter(Boolean) : [];
if (added.length){ deltaParts.push('Added ' + added.map(escapeHtml).join(', ')); }
if (removed.length){ deltaParts.push('Removed ' + removed.map(escapeHtml).join(', ')); }
if (deltaParts.length){
html += '<div class="muted" style="font-size:12px; margin-top:.35rem;">' + deltaParts.join(' · ') + '</div>';
}
}catch(_){ }
partnerSourcesEl.innerHTML = html;
}
}
}catch(_){
partnerSummaryEl.textContent = 'Partner metrics unavailable';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
}
}
function loadPartnerMetrics(){
if (!partnerSummaryEl) return;
partnerSummaryEl.textContent = 'Loading partner metrics…';
fetch('/status/partner_metrics', { cache: 'no-store' })
.then(function(resp){
if (resp.status === 404){
partnerSummaryEl.textContent = 'Diagnostics disabled (partner metrics unavailable)';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
return null;
}
return resp.json();
})
.then(function(data){ if (data) renderPartnerMetrics(data); })
.catch(function(){
partnerSummaryEl.textContent = 'Partner metrics unavailable';
if (partnerModesEl) partnerModesEl.textContent = '';
if (partnerSourcesEl) partnerSourcesEl.innerHTML = '';
});
}
loadPartnerMetrics();
// Theme status and reset
try{
var tEl = document.getElementById('themeSummary');
var resetBtn = document.getElementById('diag-theme-reset');
function renderTheme(){
if (!tEl) return;
var key = 'mtg:theme';
var stored = localStorage.getItem(key);
var html = '';
var resolved = document.documentElement.getAttribute('data-theme') || '';
html += '<div><strong>Resolved theme:</strong> ' + resolved + '</div>';
html += '<div><strong>Preference:</strong> ' + (stored ? stored : '(none)') + '</div>';
tEl.innerHTML = html;
}
renderTheme();
if (resetBtn){
resetBtn.addEventListener('click', function(){
try{ localStorage.removeItem('mtg:theme'); }catch(_){ }
// Re-apply from server default via base script by simulating system apply
try{
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var v = prefersDark ? 'dark' : 'light-blend';
document.documentElement.setAttribute('data-theme', v);
}catch(_){ }
renderTheme();
});
}
}catch(_){ }
// Combos & synergies ad-hoc tester
try{
var runBtn = document.getElementById('diag-combos-run');
var ta = document.getElementById('diag-combos-input');
var out = document.getElementById('diag-combos-out');
function parseLines(){
var v = (ta && ta.value) || '';
return v.split(/\r?\n/).map(function(s){ return s.trim(); }).filter(Boolean);
}
async function run(){
if (!ta || !out) return;
out.textContent = 'Running…';
try{
var resp = await fetch('/diagnostics/combos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names: parseLines() })});
if (!resp.ok){ out.textContent = 'Error '+resp.status; return; }
var data = await resp.json();
var lines = [];
// Versions
try{
if (data.versions){
var vLine = 'List versions: ';
if (data.versions.combos) vLine += 'combos v'+ String(data.versions.combos);
if (data.versions.synergies) vLine += (data.versions.combos? ', ' : '') + 'synergies v'+ String(data.versions.synergies);
lines.push(vLine);
}
}catch(_){ }
lines.push('Combos: '+ data.counts.combos);
(data.combos||[]).forEach(function(c){
var badges = [];
if (c.cheap_early) badges.push('cheap/early');
if (c.setup_dependent) badges.push('setup-dependent');
var tagStr = (c.tags && c.tags.length? ' ['+c.tags.join(', ')+']' : '');
var badgeStr = badges.length ? ' {'+badges.join(', ')+'}' : '';
lines.push(' - '+c.a+' + '+c.b+ tagStr + badgeStr);
});
lines.push('Synergies: '+ data.counts.synergies);
(data.synergies||[]).forEach(function(s){ lines.push(' - '+s.a+' + '+s.b+(s.tags && s.tags.length? ' ['+s.tags.join(', ')+']':'')); });
out.textContent = lines.join('\n');
}catch(e){ out.textContent = 'Failed: '+ (e && e.message? e.message : 'Unknown error'); }
}
if (runBtn){ runBtn.addEventListener('click', run); }
}catch(_){ }
try{
var p = document.getElementById('pwaStatus');
if (p){
function renderPwa(){
try{
var st = window.__pwaStatus || {};
p.innerHTML = '<div><strong>Registered:</strong> '+ (st.registered? 'Yes':'No') +'</div>' + (st.scope? '<div><strong>Scope:</strong> '+ st.scope +'</div>' : '');
}catch(_){ p.textContent = 'Unavailable'; }
}
setTimeout(renderPwa, 500);
}
}catch(_){ }
// Perf probe: listen to scroll on a card grid if present
try{
var fpsEl = document.getElementById('perf-fps');
var visEl = document.getElementById('perf-visible');
var rcEl = document.getElementById('perf-renders');
var grid = document.querySelector('.card-grid');
var last = performance.now();
var frames = 0; var renders = 0;
function tick(){
frames++;
var now = performance.now();
if (now - last >= 500){
var fps = Math.round((frames * 1000) / (now - last));
if (fpsEl) fpsEl.textContent = String(fps);
frames = 0; last = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function updateVisible(){
try{
if (!grid) return;
var tiles = grid.querySelectorAll('.card-tile');
var c = 0; tiles.forEach(function(t){ if (t.style.display !== 'none') c++; });
if (visEl) visEl.textContent = String(c);
}catch(_){ }
}
if (grid){
grid.addEventListener('scroll', updateVisible);
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
mo.observe(grid, { childList: true, subtree: true, attributes: false });
updateVisible();
}
}catch(_){ }
})();
</script>
{% endblock %}