<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What-If Simulator — sfpermits.ai</title>
{% include "fragments/head_obsidian.html" %}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" nonce="{{ csp_nonce }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/share.css') }}">
<style nonce="{{ csp_nonce }}">
body {
background: var(--obsidian);
color: var(--text-primary);
font-family: var(--sans);
margin: 0;
min-height: 100vh;
}
/* ── Page header ────────────────────────────────────────────── */
.page-header {
padding: var(--space-16) 0 var(--space-8);
border-bottom: 1px solid var(--glass-border);
margin-bottom: var(--space-8);
}
.page-header h1 {
font-family: var(--sans);
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-primary);
margin: 0 0 var(--space-3) 0;
}
.page-header .subtitle {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
margin: 0;
}
/* ── Demo link ──────────────────────────────────────────────── */
.demo-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--text-secondary);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: color 0.3s, border-color 0.3s;
margin-top: var(--space-3);
}
.demo-link:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* ── Layout: two-panel side by side ─────────────────────────── */
.what-if-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
align-items: start;
}
@media (max-width: 768px) {
.what-if-layout {
grid-template-columns: 1fr;
}
.page-header {
padding: var(--space-10) 0 var(--space-6);
}
}
/* ── Form panels ─────────────────────────────────────────────── */
.project-panel {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-6);
transition: border-color 0.3s;
}
.project-panel-label {
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
margin: 0 0 var(--space-4) 0;
}
.form-field {
margin-bottom: var(--space-4);
}
.form-field label {
display: block;
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.form-input,
.form-textarea {
width: 100%;
padding: 10px 14px;
font-family: var(--mono);
font-size: var(--text-sm);
font-weight: 300;
color: var(--text-primary);
background: var(--obsidian-light);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
outline: none;
box-sizing: border-box;
transition: border-color 0.3s, box-shadow 0.3s;
}
.form-textarea {
resize: vertical;
min-height: 90px;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--text-tertiary);
font-weight: 300;
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--accent-ring);
box-shadow: 0 0 20px var(--accent-glow);
}
/* ── Panel separator row ─────────────────────────────────────── */
.panels-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
@media (max-width: 768px) {
.panels-row {
grid-template-columns: 1fr;
}
}
/* ── Compare button ──────────────────────────────────────────── */
.compare-btn {
font-family: var(--mono);
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-secondary);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 10px 24px;
cursor: pointer;
transition: border-color 0.3s, color 0.3s, background 0.3s;
width: 100%;
}
.compare-btn:hover:not(:disabled) {
border-color: var(--glass-hover);
color: var(--text-primary);
background: var(--obsidian-light);
}
.compare-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Loading state ───────────────────────────────────────────── */
.loading-area {
display: none;
padding: var(--space-8);
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
margin-top: var(--space-6);
}
.loading-area.visible {
display: block;
}
/* Skeleton rows to mirror comparison table */
.skeleton {
background: var(--glass);
border-radius: var(--radius-sm);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.skeleton--heading { height: 20px; }
.skeleton--text { height: 12px; }
.skeleton-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--glass-border);
gap: var(--space-4);
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.04; }
50% { opacity: 0.10; }
}
.loading-label {
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--accent);
margin-bottom: var(--space-6);
}
/* ── Empty state ─────────────────────────────────────────────── */
.empty-state {
padding: var(--space-12) var(--space-8);
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
text-align: center;
margin-top: var(--space-6);
}
.empty-state__icon {
font-family: var(--mono);
font-size: var(--text-2xl);
color: var(--text-ghost);
margin-bottom: var(--space-4);
}
.empty-state__title {
font-family: var(--sans);
font-size: var(--text-base);
font-weight: 400;
color: var(--text-secondary);
margin: 0 0 var(--space-3);
}
.empty-state__desc {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-tertiary);
margin: 0 0 var(--space-6);
}
/* ── Results area ────────────────────────────────────────────── */
.results-area {
margin-top: var(--space-6);
display: none;
}
.results-area.visible {
display: block;
}
/* ── Comparison table wrapper ────────────────────────────────── */
.comparison-table-wrap {
overflow-x: auto;
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-6);
}
.comparison-table {
width: 100%;
border-collapse: collapse;
font-family: var(--sans);
font-size: var(--text-sm);
}
.comparison-table th {
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
text-align: left;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--glass-border);
white-space: nowrap;
background: var(--obsidian-light);
}
.comparison-table th:first-child {
border-radius: var(--radius-md) 0 0 0;
}
.comparison-table td {
padding: 10px var(--space-4);
color: var(--text-secondary);
border-bottom: 1px solid var(--glass-border);
vertical-align: top;
}
.comparison-table td.row-label {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: 400;
white-space: nowrap;
}
.comparison-table td.val-col {
font-family: var(--mono);
font-size: var(--text-sm);
font-weight: 300;
color: var(--text-primary);
}
.comparison-table tr:last-child td {
border-bottom: none;
}
/* Red/green delta indicators */
.diff-better {
color: var(--signal-green);
}
.diff-worse {
color: var(--signal-red);
}
.diff-neutral {
color: var(--text-secondary);
}
/* ── Strategy callout ────────────────────────────────────────── */
.strategy-callout {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-left: 3px solid var(--accent);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
padding: var(--space-6);
margin-bottom: var(--space-6);
}
.strategy-callout__label {
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 var(--space-3) 0;
}
.strategy-callout__body {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-primary);
margin: 0;
line-height: 1.6;
}
/* ── Full markdown result (fallback / detailed) ──────────────── */
.result-markdown {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-6);
}
.result-markdown h1,
.result-markdown h2,
.result-markdown h3 {
font-family: var(--sans);
font-weight: 400;
color: var(--text-primary);
margin-top: var(--space-6);
margin-bottom: var(--space-3);
}
.result-markdown h1 { font-size: var(--text-xl); margin-top: 0; }
.result-markdown h2 { font-size: var(--text-lg); border-bottom: 1px solid var(--glass-border); padding-bottom: var(--space-2); }
.result-markdown h3 { font-size: var(--text-base); }
.result-markdown p {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
line-height: 1.65;
margin: var(--space-3) 0;
}
.result-markdown strong {
font-family: var(--mono);
font-size: var(--text-base);
font-weight: 500;
color: var(--text-primary);
}
.result-markdown ul, .result-markdown ol {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
line-height: 1.65;
padding-left: var(--space-6);
margin: var(--space-3) 0;
}
.result-markdown code {
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--accent);
background: var(--obsidian-light);
border: 1px solid var(--glass-border);
border-radius: 3px;
padding: 1px 5px;
}
.result-markdown hr {
border: none;
border-top: 1px solid var(--glass-border);
margin: var(--space-6) 0;
}
.result-markdown table {
width: 100%;
border-collapse: collapse;
font-family: var(--mono);
font-size: var(--text-sm);
margin: var(--space-4) 0;
}
.result-markdown th {
font-family: var(--sans);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--glass-border);
}
.result-markdown td {
color: var(--text-primary);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--glass-border);
}
/* ── Error state ─────────────────────────────────────────────── */
.error-box {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--signal-red);
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-top: var(--space-6);
}
.error-box a {
color: var(--accent);
text-decoration: none;
}
.error-box a:hover {
text-decoration: underline;
}
/* Anonymous user soft CTA */
.anon-cta {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
margin-top: var(--space-6);
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
}
.anon-cta-text {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-tertiary);
}
</style>
</head>
<body>
{% include "fragments/nav.html" %}
<main>
<div class="obs-container">
<header class="page-header">
<h1>What-If Simulator</h1>
<p class="subtitle">Compare how project scope changes affect timeline, fees, review path, and revision risk — before you file.</p>
<a href="?demo=kitchen-vs-full" class="demo-link">Try demo: Kitchen remodel vs. full gut renovation →</a>
</header>
<form id="what-if-form" autocomplete="off">
<div class="panels-row">
<!-- Project A -->
<div class="project-panel">
<p class="project-panel-label">Project A — Base</p>
<div class="form-field">
<label for="scope-a">Project scope</label>
<textarea
id="scope-a"
name="scope_a"
class="form-textarea"
placeholder="e.g. Kitchen remodel, $80K, Mission District"
rows="4"
required
></textarea>
</div>
<div class="form-field">
<label for="cost-a">Estimated cost (optional)</label>
<input
type="text"
id="cost-a"
name="cost_a"
class="form-input"
placeholder="e.g. $80,000"
autocomplete="off"
>
</div>
<div class="form-field">
<label for="neighborhood-a">Neighborhood (optional)</label>
<input
type="text"
id="neighborhood-a"
name="neighborhood_a"
class="form-input"
placeholder="e.g. Mission, Noe Valley, SoMa"
autocomplete="off"
>
</div>
</div>
<!-- Project B -->
<div class="project-panel">
<p class="project-panel-label">Project B — Modified scope</p>
<div class="form-field">
<label for="scope-b">Modified scope</label>
<textarea
id="scope-b"
name="scope_b"
class="form-textarea"
placeholder="e.g. Full gut renovation + ADU, $350K, Mission District"
rows="4"
></textarea>
</div>
<div class="form-field">
<label for="cost-b">Estimated cost (optional)</label>
<input
type="text"
id="cost-b"
name="cost_b"
class="form-input"
placeholder="e.g. $350,000"
autocomplete="off"
>
</div>
<div class="form-field">
<label for="label-b">Label for Project B (optional)</label>
<input
type="text"
id="label-b"
name="label_b"
class="form-input"
placeholder="e.g. Full gut + ADU"
autocomplete="off"
>
</div>
</div>
{% include "components/share_button.html" %}
</div>
<button type="submit" id="submit-btn" class="compare-btn">
Compare projects →
</button>
<!-- add-variation: extend via Project B panel above -->
</form>
<!-- Loading skeleton -->
<div class="loading-area" id="loading-area">
<div class="loading-label">Simulating scenarios…</div>
<div class="skeleton skeleton--heading" style="width: 40%; margin-bottom: 20px;"></div>
<div class="skeleton-row">
<div class="skeleton skeleton--text" style="width: 120px;"></div>
<div class="skeleton skeleton--text" style="width: 80px;"></div>
<div class="skeleton skeleton--text" style="width: 80px;"></div>
</div>
<div class="skeleton-row">
<div class="skeleton skeleton--text" style="width: 100px;"></div>
<div class="skeleton skeleton--text" style="width: 90px;"></div>
<div class="skeleton skeleton--text" style="width: 90px;"></div>
</div>
<div class="skeleton-row">
<div class="skeleton skeleton--text" style="width: 110px;"></div>
<div class="skeleton skeleton--text" style="width: 70px;"></div>
<div class="skeleton skeleton--text" style="width: 70px;"></div>
</div>
<div class="skeleton-row">
<div class="skeleton skeleton--text" style="width: 130px;"></div>
<div class="skeleton skeleton--text" style="width: 85px;"></div>
<div class="skeleton skeleton--text" style="width: 85px;"></div>
</div>
</div>
<!-- Empty state (shown on page load) -->
<div class="empty-state" id="empty-state">
<div class="empty-state__icon">—</div>
<h2 class="empty-state__title">Compare two project scopes</h2>
<p class="empty-state__desc">Describe Project A and Project B above, then run the simulation to see how timeline, fees, review path, and revision risk differ.</p>
<a href="?demo=kitchen-vs-full" class="demo-link">See demo: Kitchen remodel vs. full gut renovation →</a>
</div>
<!-- Results: structured comparison table + strategy callout -->
<div class="results-area" id="results">
<!-- Strategy callout -->
<div class="strategy-callout" id="strategy-callout" style="display:none;">
<p class="strategy-callout__label">Recommendation</p>
<p class="strategy-callout__body" id="strategy-text"></p>
</div>
<!-- Comparison table -->
<div class="comparison-table-wrap" id="comparison-table-wrap" style="display:none;">
<table class="comparison-table" id="comparison-table">
<thead>
<tr>
<th>Metric</th>
<th id="th-project-a">Project A</th>
<th id="th-project-b">Project B</th>
</tr>
</thead>
<tbody id="comparison-tbody">
</tbody>
</table>
</div>
<!-- Full markdown detail (the raw AI output) -->
<div class="result-markdown" id="result-markdown" style="display:none;"></div>
</div>
<!-- Error state -->
<div class="error-box" id="error-box" style="display:none;"></div>
{% if not g.user %}
<div class="anon-cta" id="anon-cta">
<span class="anon-cta-text">Simulate your own projects —</span>
<a href="/beta/join" class="ghost-cta">Sign up free →</a>
</div>
{% endif %}
</div>
</main>
<script src="{{ url_for('static', filename='js/share.js') }}" defer></script>
<script nonce="{{ csp_nonce }}">
(function () {
'use strict';
// ── Demo data ──────────────────────────────────────────────────────────
var DEMO_DATA = {
'kitchen-vs-full': {
scopeA: 'Kitchen remodel with new cabinets and appliances, $80K, Mission District',
costA: '$80,000',
neighborhoodA: 'Mission',
scopeB: 'Full gut renovation of 2-bed flat including kitchen, bathrooms, structural + new ADU in garage, $350K',
costB: '$350,000',
labelB: 'Full gut + ADU'
}
};
// ── DOM refs ───────────────────────────────────────────────────────────
var form = document.getElementById('what-if-form');
var submitBtn = document.getElementById('submit-btn');
var loadingArea = document.getElementById('loading-area');
var emptyState = document.getElementById('empty-state');
var resultsArea = document.getElementById('results');
var errorBox = document.getElementById('error-box');
var strategyCallout = document.getElementById('strategy-callout');
var strategyText = document.getElementById('strategy-text');
var compTableWrap = document.getElementById('comparison-table-wrap');
var compTbody = document.getElementById('comparison-tbody');
var thA = document.getElementById('th-project-a');
var thB = document.getElementById('th-project-b');
var resultMarkdown = document.getElementById('result-markdown');
// ── Check for demo URL param ───────────────────────────────────────────
var urlParams = new URLSearchParams(window.location.search);
var demoKey = urlParams.get('demo');
if (demoKey && DEMO_DATA[demoKey]) {
var d = DEMO_DATA[demoKey];
document.getElementById('scope-a').value = d.scopeA;
document.getElementById('cost-a').value = d.costA;
document.getElementById('neighborhood-a').value = d.neighborhoodA;
document.getElementById('scope-b').value = d.scopeB;
document.getElementById('cost-b').value = d.costB;
document.getElementById('label-b').value = d.labelB;
// Auto-run after short delay so user sees the filled form
setTimeout(function () {
form.dispatchEvent(new Event('submit', { cancelable: true }));
}, 400);
}
// ── Helpers ────────────────────────────────────────────────────────────
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function buildDescription(scope, cost, neighborhood) {
var parts = [scope.trim()];
if (cost && cost.trim()) parts.push(cost.trim());
if (neighborhood && neighborhood.trim()) parts.push(neighborhood.trim());
return parts.join(', ');
}
// ── UI state transitions ───────────────────────────────────────────────
function showLoading() {
submitBtn.disabled = true;
loadingArea.classList.add('visible');
emptyState.style.display = 'none';
resultsArea.classList.remove('visible');
errorBox.style.display = 'none';
}
function hideLoading() {
submitBtn.disabled = false;
loadingArea.classList.remove('visible');
}
function showError(message, is401) {
hideLoading();
errorBox.style.display = 'block';
if (is401) {
errorBox.innerHTML = 'Please <a href="/auth/login">log in</a> to use the What-If Simulator.';
} else {
errorBox.textContent = message || 'An error occurred. Please try again.';
}
}
// ── Parse comparison rows from markdown ────────────────────────────────
// Extracts key metrics from the raw markdown table so we can render
// a structured comparison UI with red/green delta indicators.
var ROW_DEFS = [
{ key: 'permits', label: 'Permit Type', re: /Permits?\s*\|([^|]+)\|([^|\n]+)/i },
{ key: 'review_path', label: 'Review Path', re: /Review Path\s*\|([^|]+)\|([^|\n]+)/i },
{ key: 'timeline_p50', label: 'Timeline (p50)', re: /Timeline \(p50\)\s*\|([^|]+)\|([^|\n]+)/i },
{ key: 'timeline_p75', label: 'Timeline (p75)', re: /Timeline \(p75\)\s*\|([^|]+)\|([^|\n]+)/i },
{ key: 'fees', label: 'Est. DBI Fees', re: /Est\. DBI Fees\s*\|([^|]+)\|([^|\n]+)/i },
{ key: 'revision_risk', label: 'Revision Risk', re: /Revision Risk\s*\|([^|]+)\|([^|\n]+)/i },
];
function parseTableRows(md) {
var rows = [];
// Find all data rows in the markdown table (skip header rows with ---|---|---)
var lines = md.split('\n');
var tableStarted = false;
var headerParsed = false;
var tableRows = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!tableStarted) {
if (/^\|.*Scenario.*\|/.test(line) || /^\|.*\*\*Base\*\*/.test(line)) {
tableStarted = true;
headerParsed = false;
continue;
}
continue;
}
// Skip separator rows
if (/^\|[\s\-|]+\|$/.test(line)) {
if (!headerParsed) { headerParsed = true; }
continue;
}
// Stop at blank line or second section header
if (!line || /^#/.test(line)) break;
// Parse data rows
var cells = line.split('|').map(function(c) { return c.trim(); }).filter(Boolean);
if (cells.length >= 2) {
tableRows.push(cells);
}
}
return tableRows;
}
function isWorse(metric, valA, valB) {
// Heuristics: higher days / fees / risk = worse for B
var daysA = parseInt(valA), daysB = parseInt(valB);
if (!isNaN(daysA) && !isNaN(daysB)) return daysB > daysA * 1.15;
if (metric === 'revision_risk') {
var riskOrder = { 'LOW': 1, 'MODERATE': 2, 'HIGH': 3 };
var rA = riskOrder[valA.split(' ')[0].toUpperCase()];
var rB = riskOrder[valB.split(' ')[0].toUpperCase()];
if (rA && rB) return rB > rA;
}
if (metric === 'review_path') {
if (valA.toLowerCase().indexOf('otc') >= 0 && valB.toLowerCase().indexOf('in-house') >= 0) return true;
}
return false;
}
function isBetter(metric, valA, valB) {
var daysA = parseInt(valA), daysB = parseInt(valB);
if (!isNaN(daysA) && !isNaN(daysB)) return daysB < daysA * 0.85;
if (metric === 'revision_risk') {
var riskOrder = { 'LOW': 1, 'MODERATE': 2, 'HIGH': 3 };
var rA = riskOrder[valA.split(' ')[0].toUpperCase()];
var rB = riskOrder[valB.split(' ')[0].toUpperCase()];
if (rA && rB) return rB < rA;
}
if (metric === 'review_path') {
if (valA.toLowerCase().indexOf('in-house') >= 0 && valB.toLowerCase().indexOf('otc') >= 0) return true;
}
return false;
}
function renderResults(markdown, labelA, labelB) {
hideLoading();
emptyState.style.display = 'none';
errorBox.style.display = 'none';
// Update table headers
thA.textContent = labelA || 'Project A';
thB.textContent = labelB || 'Project B';
// Parse table rows
var tableRows = parseTableRows(markdown);
// Row labels and their positions in the comparison table
var METRIC_LABELS = [
'Permit Type / Path',
'Review Path',
'Timeline (p50)',
'Timeline (p75)',
'Est. DBI Fees',
'Revision Risk'
];
var METRIC_KEYS = [
'permits',
'review_path',
'timeline_p50',
'timeline_p75',
'fees',
'revision_risk'
];
// Try to build structured comparison from parsed rows
var structuredRows = [];
if (tableRows.length >= 1) {
var baseRow = tableRows[0]; // Base / Project A
var varRow = tableRows[1]; // Variation / Project B
// Columns: label, description, permits, review_path, timeline_p50, timeline_p75, fees, revision_risk
var colMap = [null, null, 'permits', 'review_path', 'timeline_p50', 'timeline_p75', 'fees', 'revision_risk'];
var labelMap = [null, null, 'Permit Type', 'Review Path', 'Timeline (p50)', 'Timeline (p75)', 'Est. DBI Fees', 'Revision Risk'];
for (var ci = 2; ci < colMap.length; ci++) {
var metricKey = colMap[ci];
var metricLabel = labelMap[ci];
var aVal = baseRow[ci] ? baseRow[ci].replace(/\*+/g, '').trim() : 'N/A';
var bVal = varRow && varRow[ci] ? varRow[ci].replace(/\*+/g, '').trim() : 'N/A';
structuredRows.push({
label: metricLabel,
key: metricKey,
a: aVal,
b: bVal
});
}
}
// Render structured table if we got data
if (structuredRows.length > 0) {
var tbodyHtml = '';
for (var ri = 0; ri < structuredRows.length; ri++) {
var row = structuredRows[ri];
var worse = isWorse(row.key, row.a, row.b);
var better = !worse && isBetter(row.key, row.a, row.b);
var bClass = worse ? 'diff-worse' : (better ? 'diff-better' : 'diff-neutral');
tbodyHtml += '<tr>';
tbodyHtml += '<td class="row-label">' + escapeHtml(row.label) + '</td>';
tbodyHtml += '<td class="val-col">' + escapeHtml(row.a) + '</td>';
tbodyHtml += '<td class="val-col ' + bClass + '">' + escapeHtml(row.b) + '</td>';
tbodyHtml += '</tr>';
}
compTbody.innerHTML = tbodyHtml;
compTableWrap.style.display = 'block';
// Build strategy callout from Delta section
var deltaMatch = markdown.match(/## Delta vs\. Base[\s\S]*?(?=\n##|$)/);
if (deltaMatch) {
var deltaSection = deltaMatch[0];
var bullets = deltaSection.match(/- \*\*[^:]+:\*\*[^\n]+/g);
if (bullets && bullets.length > 0) {
// Summarize key differences
var summaryParts = bullets.map(function(b) {
return b.replace(/^- \*\*/, '').replace(/\*\*/, '').replace(/:\s*/, ': ');
});
strategyText.textContent = summaryParts.slice(0, 3).join(' | ');
strategyCallout.style.display = 'block';
}
}
// Show full markdown below the table
resultMarkdown.innerHTML = '<div>' + marked.parse(markdown) + '</div>';
resultMarkdown.style.display = 'block';
} else {
// Fallback: just render the markdown
compTableWrap.style.display = 'none';
resultMarkdown.innerHTML = '<div>' + marked.parse(markdown) + '</div>';
resultMarkdown.style.display = 'block';
}
resultsArea.classList.add('visible');
}
// ── Form submit ────────────────────────────────────────────────────────
form.addEventListener('submit', function (e) {
e.preventDefault();
var scopeA = document.getElementById('scope-a').value.trim();
if (!scopeA) {
document.getElementById('scope-a').focus();
return;
}
var costA = document.getElementById('cost-a').value.trim();
var neighborhoodA = document.getElementById('neighborhood-a').value.trim();
var scopeB = document.getElementById('scope-b').value.trim();
var costB = document.getElementById('cost-b').value.trim();
var labelB = document.getElementById('label-b').value.trim() || 'Project B';
var baseDescription = buildDescription(scopeA, costA, neighborhoodA);
// Build variations array
var variations = [];
if (scopeB) {
var bDesc = buildDescription(scopeB, costB, neighborhoodA);
variations.push({ label: labelB, description: bDesc });
}
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
showLoading();
fetch('/api/what-if', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
base_description: baseDescription,
variations: variations
})
})
.then(function (resp) {
if (resp.status === 401) {
showError('', true);
return null;
}
return resp.json();
})
.then(function (data) {
if (data === null) return;
if (data.error) {
showError(data.error, false);
} else if (data.result) {
renderResults(data.result, 'Project A', labelB);
} else {
showError('No results returned. Please try again.', false);
}
})
.catch(function () {
showError('Network error. Check your connection and try again.', false);
});
});
})();
</script>
{% include 'fragments/feedback_widget.html' %}
<script nonce="{{ csp_nonce }}" src="/static/admin-feedback.js" defer></script>
<script nonce="{{ csp_nonce }}" src="/static/admin-tour.js" defer></script>
</body>
</html>