← back to Ventura Corridor

public/buildings.html

533 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Buildings · DW · Ventura Corridor</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js" defer></script>
<style>
  :root {
    --noir: #0a0a0c; --noir-rise: #131316; --ink: #f0ece2;
    --ink-mute: #888475; --metal: #b89968; --metal-glow: #d4b683;
    --rule: #2a2622; --green: #6a9b73; --red: #b66565;
    --serif: 'Cormorant Garamond', 'Times New Roman', serif;
    --sans: 'Inter', -apple-system, system-ui, sans-serif;
    --mono: 'JetBrains Mono', ui-monospace, monospace;
  }
  @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Inter:wght@200;300;400;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
  html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
  * { box-sizing: border-box; }
  header { position: sticky; top: 0; z-index: 10;
    background: linear-gradient(180deg, rgba(10,10,12,.97) 70%, rgba(10,10,12,0));
    padding: 22px 32px 16px; border-bottom: 1px solid var(--rule);
    display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap; }
  header h1 { font-family: var(--serif); font-weight: 400; font-size: 28px; margin: 0; letter-spacing: -0.01em; }
  header h1 em { font-style: italic; color: var(--metal); }
  .sub { font-size: 11px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
  nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
  nav.tabs a { font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
    color: var(--ink-mute); text-decoration: none; padding: 5px 10px; border: 1px solid var(--rule); }
  nav.tabs a.active, nav.tabs a:hover { color: var(--metal); border-color: var(--metal); }

  .controls {
    padding: 16px 32px; display: flex; gap: 16px; flex-wrap: wrap; align-items: center;
    border-bottom: 1px solid var(--rule); background: var(--noir-rise);
  }
  .controls .ctrl { display: flex; flex-direction: column; gap: 4px; }
  .controls label { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }
  .controls select, .controls input {
    background: var(--noir); border: 1px solid var(--rule); color: var(--ink);
    padding: 6px 12px; font-family: var(--sans); font-size: 12px; min-width: 140px; border-radius: 0;
  }

  .stats {
    padding: 18px 32px; display: flex; gap: 36px; flex-wrap: wrap; align-items: baseline;
    border-bottom: 1px solid var(--rule);
  }
  .stats .stat { display: flex; flex-direction: column; gap: 2px; }
  .stats .num { font-family: var(--serif); font-style: italic; font-size: 28px; color: var(--metal-glow); line-height: 1; }
  .stats .lbl { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }

  main { padding: 32px; max-width: 1400px; margin: 0 auto; }

  .bldg {
    margin-bottom: 18px; padding: 18px 22px;
    background: var(--noir-rise); border: 1px solid var(--rule);
    transition: border-color 200ms;
  }
  .bldg:hover { border-color: var(--metal); }
  .bldg.dw { border-color: var(--metal-glow); background: rgba(212,182,131,0.04); }
  .bldg-head { display: grid; grid-template-columns: 1fr auto auto auto auto auto auto; gap: 18px; align-items: center; }
  .bldg-head .addr {
    font-family: var(--serif); font-style: italic; font-size: 22px; color: var(--ink);
    cursor: pointer;
  }
  .bldg-head .addr:hover { color: var(--metal); }
  .bldg-head .city { color: var(--ink-mute); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; }
  .bldg-head .stat-cell { display: flex; flex-direction: column; gap: 2px; min-width: 60px; }
  .bldg-head .stat-cell .v { font-family: var(--mono); font-size: 14px; color: var(--ink); }
  .bldg-head .stat-cell .v.metal { color: var(--metal-glow); }
  .bldg-head .stat-cell .v.green { color: var(--green); }
  .bldg-head .stat-cell .l { font-size: 7px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }

  .bldg-bar {
    height: 6px; background: var(--noir); margin-top: 12px; display: flex; overflow: hidden;
    border: 1px solid var(--rule);
  }
  .bldg-bar .seg-walked   { background: var(--green); }
  .bldg-bar .seg-replied  { background: var(--metal-glow); }
  .bldg-bar .seg-pitched  { background: var(--metal); }
  .bldg-bar .seg-skipped  { background: var(--rule); }
  .bldg-bar .seg-unpitched{ background: rgba(255,255,255,0.04); }

  .roster {
    margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--rule);
    display: none;
  }
  .bldg.open .roster { display: block; }
  .roster table { width: 100%; border-collapse: collapse; font-size: 12px; }
  .roster th { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--rule);
    font-size: 8px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); }
  .roster td { padding: 6px 10px; border-bottom: 1px solid var(--rule); }
  .roster td.suite { color: var(--metal); font-family: var(--mono); font-size: 11px; }
  .roster td.name { font-family: var(--serif); font-size: 14px; }
  .roster td.naics { color: var(--ink-mute); font-size: 10px; }
  .roster .pill {
    font-family: var(--mono); font-size: 8px; letter-spacing: .18em; text-transform: uppercase;
    padding: 2px 6px; border: 1px solid var(--rule); color: var(--ink-mute);
  }
  .roster .pill.draft     { color: var(--ink-mute); }
  .roster .pill.researched{ color: var(--metal); border-color: var(--metal); }
  .roster .pill.sent      { color: var(--metal-glow); border-color: var(--metal-glow); }
  .roster .pill.replied   { color: var(--metal-glow); border-color: var(--metal-glow); }
  .roster .pill.won       { color: var(--green); border-color: var(--green); }
  .roster .pill.lost      { color: var(--red); border-color: var(--red); opacity: 0.6; }
  .roster .pill.skip      { color: var(--ink-mute); opacity: 0.5; }
  .roster .pill.no-pitch  { color: var(--ink-mute); opacity: 0.4; font-style: italic; }
  .roster a {
    color: var(--metal); text-decoration: none; font-size: 9px; letter-spacing: .18em;
    text-transform: uppercase; padding: 2px 8px; border: 1px solid var(--rule);
  }
  .roster a:hover { border-color: var(--metal); }

  .empty { text-align: center; padding: 60px 32px; color: var(--ink-mute); font-style: italic; }
  .loading { text-align: center; padding: 60px; color: var(--ink-mute); font-size: 11px; letter-spacing: .26em; text-transform: uppercase; }
</style>
</head>
<body>

<header>
  <div>
    <h1>Buildings · <em>360°</em> on every multi-tenant address</h1>
    <div class="sub">tenant roster · walked status · proximity to DW · click an address to expand</div>
  </div>
  <nav class="tabs">
    <a href="/">map</a>
    <a href="/today.html">today</a>
    <a href="/pitches.html">pitches</a>
    <a href="/walk-route.html">walk route</a>
    <a href="/responses.html">responses</a>
    <a href="/postcards.html">postcards</a>
    <a href="/buildings.html" class="active">buildings</a>
    <a href="/crawl-derby.html">🏇 derby</a>
    <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
  </nav>
</header>

<div class="controls">
  <div class="ctrl">
    <label>min tenants</label>
    <select id="min-tenants">
      <option value="2">2+</option>
      <option value="5" selected>5+</option>
      <option value="10">10+</option>
      <option value="20">20+</option>
      <option value="50">50+ (towers only)</option>
    </select>
  </div>
  <div class="ctrl">
    <label>sort</label>
    <select id="sort">
      <option value="distance" selected>Distance from DW</option>
      <option value="tenants">Tenant count</option>
      <option value="pitched_pct">% pitched</option>
      <option value="walked">Walked count</option>
    </select>
  </div>
  <div class="ctrl">
    <label>filter address</label>
    <input id="filter" placeholder="e.g. 15442">
  </div>
</div>

<div class="stats">
  <div class="stat"><div class="num" id="s-buildings">—</div><div class="lbl">Buildings shown</div></div>
  <div class="stat"><div class="num" id="s-tenants">—</div><div class="lbl">Total tenants</div></div>
  <div class="stat"><div class="num" id="s-pitched">—</div><div class="lbl">Pitched</div></div>
  <div class="stat"><div class="num" id="s-walked">—</div><div class="lbl">Walked</div></div>
  <div class="stat"><div class="num" id="s-unpitched">—</div><div class="lbl">No pitch yet</div></div>
  <div class="stat"><div class="num" id="s-dupes" style="color:var(--metal-glow);cursor:pointer" onclick="refreshDupes()" title="click to recompute">—</div><div class="lbl">🔗 Dupe pairs (corridor-wide)</div></div>
  <div class="stat" style="border-left:1px solid var(--rule);padding-left:24px">
    <button onclick="bulkMerge(1.0, null, true)" style="background:transparent;border:1px solid var(--metal);color:var(--metal);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">Preview · merge all 1.00</button>
    <button onclick="bulkMerge(1.0, null, false)" style="background:transparent;border:1px solid var(--metal-glow);color:var(--metal-glow);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;margin-left:6px">⚡ Sweep merge 1.00</button>
    <a href="/api/buildings.csv?min_tenants=2" download style="background:transparent;border:1px solid var(--metal);color:var(--metal);padding:7px 14px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;text-decoration:none;margin-left:6px">⬇ Buildings CSV</a>
  </div>
</div>

<section id="priority-section" style="padding:24px 32px;border-bottom:1px solid var(--rule);background:radial-gradient(ellipse at top right, rgba(212,182,131,0.06), transparent 70%)">
  <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:14px;flex-wrap:wrap;gap:12px">
    <h2 style="font-family:var(--serif);font-style:italic;font-weight:400;font-size:24px;color:var(--metal-glow);margin:0">Walk here next</h2>
    <div style="display:flex;gap:14px;align-items:baseline;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)">
      <span>radius
        <select id="prio-radius" style="background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:3px 8px;font-size:11px">
          <option value="0.25">0.25 mi</option>
          <option value="0.5" selected>0.5 mi</option>
          <option value="1.0">1.0 mi</option>
          <option value="2.0">2.0 mi</option>
        </select>
      </span>
      <span id="prio-summary">—</span>
    </div>
  </div>
  <div id="priority-list" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px"></div>
  <div style="font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-top:10px">
    score = unpitched doors × (1 / miles from DW) · clears once Walked > 0
  </div>
</section>

<main id="list">
  <div class="loading">Loading building roster…</div>
</main>

<script>
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }

let allBuildings = [];

async function load() {
  const minT = document.getElementById('min-tenants').value;
  const sort = document.getElementById('sort').value;
  const data = await fetch(`/api/buildings?min_tenants=${minT}&sort=${sort}`).then(r => r.json());
  allBuildings = data.rows || [];
  applyFilter();
}

function applyFilter() {
  const f = document.getElementById('filter').value.trim().toLowerCase();
  const rows = f
    ? allBuildings.filter(b => (b.bldg_address || '').toLowerCase().includes(f) || (b.city || '').toLowerCase().includes(f))
    : allBuildings;
  render(rows);
}

function render(rows) {
  document.getElementById('s-buildings').textContent = rows.length;
  document.getElementById('s-tenants').textContent  = rows.reduce((a,b) => a + Number(b.tenants_total || 0), 0).toLocaleString();
  document.getElementById('s-pitched').textContent  = rows.reduce((a,b) => a + Number(b.pitched || 0), 0).toLocaleString();
  document.getElementById('s-walked').textContent   = rows.reduce((a,b) => a + Number(b.walked || 0), 0).toLocaleString();
  document.getElementById('s-unpitched').textContent= rows.reduce((a,b) => a + Number(b.unpitched || 0), 0).toLocaleString();

  if (rows.length === 0) {
    document.getElementById('list').innerHTML = `<div class="empty">No buildings match.</div>`;
    return;
  }

  document.getElementById('list').innerHTML = rows.map(b => {
    const total = Number(b.tenants_total || 1);
    const walked    = Number(b.walked    || 0);
    const replied   = Number(b.replied   || 0);
    const pitched   = Number(b.pitched   || 0);
    const skipped   = Number(b.skipped   || 0);
    const unpitched = Number(b.unpitched || 0);
    const pitchedNotWalked = Math.max(0, pitched - walked - replied);
    const isDW = (b.bldg_address || '').startsWith('15442 ');
    return `
      <div class="bldg ${isDW ? 'dw' : ''}" data-addr="${encodeURIComponent(b.bldg_address)}">
        <div class="bldg-head">
          <div class="addr" onclick="toggleBldg(this)">${escapeHtml(b.bldg_address)}${isDW ? ' ⚡' : ''}</div>
          <div class="stat-cell"><div class="v">${b.city || ''}</div><div class="l">city</div></div>
          <div class="stat-cell"><div class="v metal">${b.dw_miles != null ? b.dw_miles + ' mi' : '—'}</div><div class="l">from DW</div></div>
          <div class="stat-cell"><div class="v">${b.tenants_total}</div><div class="l">tenants</div></div>
          <div class="stat-cell"><div class="v metal">${pitched}</div><div class="l">pitched</div></div>
          <div class="stat-cell"><div class="v green">${walked}</div><div class="l">walked</div></div>
          <div class="stat-cell"><div class="v green">${b.won || 0}</div><div class="l">won</div></div>
          ${b.dominant_pitch_type ? `<div class="stat-cell"><div class="v" style="font-family:var(--serif);font-style:italic;color:var(--metal-glow);font-size:13px">${escapeHtml(b.dominant_pitch_type.replace(/-/g,' '))}</div><div class="l">dominant</div></div>` : ''}
        </div>
        <div class="bldg-bar">
          <div class="seg-walked"   style="width:${100*walked/total}%"  title="walked: ${walked}"></div>
          <div class="seg-replied"  style="width:${100*replied/total}%" title="replied: ${replied}"></div>
          <div class="seg-pitched"  style="width:${100*pitchedNotWalked/total}%" title="pitched (not walked): ${pitchedNotWalked}"></div>
          <div class="seg-skipped"  style="width:${100*skipped/total}%" title="skipped: ${skipped}"></div>
          <div class="seg-unpitched" style="width:${100*unpitched/total}%" title="no pitch: ${unpitched}"></div>
        </div>
        <div class="roster" data-loaded="0"></div>
      </div>`;
  }).join('');
}

async function toggleBldg(addrEl) {
  const bldgEl = addrEl.closest('.bldg');
  bldgEl.classList.toggle('open');
  if (!bldgEl.classList.contains('open')) return;
  const roster = bldgEl.querySelector('.roster');
  if (roster.dataset.loaded === '1') return;
  roster.innerHTML = `<div class="loading" style="padding:14px">Loading roster…</div>`;
  try {
    const [data, dupesData, ptData] = await Promise.all([
      fetch(`/api/buildings/${bldgEl.dataset.addr}`).then(r => r.json()),
      fetch(`/api/buildings/${bldgEl.dataset.addr}/dupes`).then(r => r.json()).catch(() => ({ pairs: [] })),
      fetch(`/api/buildings/${bldgEl.dataset.addr}/pitch-types`).then(r => r.json()).catch(() => ({ rows: [] }))
    ]);
    const rows = data.rows || [];
    roster.dataset.loaded = '1';
    if (rows.length === 0) {
      roster.innerHTML = `<div class="empty">No tenants on file.</div>`;
      return;
    }
    // Build a map: business_id → list of likely-same partners (other_id, partner_name, match_type, confidence)
    const dupeMap = {};
    for (const p of (dupesData.pairs || [])) {
      (dupeMap[p.id_a] ||= []).push({ other_id: p.id_b, name: p.name_b, suite: p.suite_b, match_type: p.match_type, confidence: p.confidence });
      (dupeMap[p.id_b] ||= []).push({ other_id: p.id_a, name: p.name_a, suite: p.suite_a, match_type: p.match_type, confidence: p.confidence });
    }
    const dupeBadge = (bizId) => {
      const links = dupeMap[bizId] || [];
      if (!links.length) return '';
      const top = links.sort((a,b) => b.confidence - a.confidence).slice(0, 3);
      return `<span class="pill" style="color:var(--metal-glow);border-color:var(--metal-glow);font-size:7px;margin-left:6px" title="Likely same as: ${top.map(t => escapeHtml(t.name) + ' (' + t.match_type + ' ' + t.confidence + ')').join(' · ')}">🔗 ${links.length}</span>`;
    };
    const dupePairs = dupesData.pairs || [];
    const dupeCount = dupePairs.length;
    const dupePairsHtml = dupeCount ? `
      <div style="padding:12px 14px;background:rgba(212,182,131,0.05);border:1px solid var(--metal);margin-bottom:12px">
        <div style="font-size:11px;color:var(--metal-glow);letter-spacing:.18em;text-transform:uppercase;margin-bottom:8px">🔗 ${dupeCount} likely same-tenant pair${dupeCount === 1 ? '' : 's'}</div>
        ${dupePairs.map(p => `
          <div style="display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:6px 0;border-top:1px solid var(--rule);font-size:11px">
            <div>
              <span style="font-family:var(--serif);font-size:13px">${escapeHtml(p.name_a)}</span>${p.suite_a ? ` <span style="color:var(--ink-mute)">(${escapeHtml(p.suite_a)})</span>` : ''}
              <span style="color:var(--metal);margin:0 8px">↔</span>
              <span style="font-family:var(--serif);font-size:13px">${escapeHtml(p.name_b)}</span>${p.suite_b ? ` <span style="color:var(--ink-mute)">(${escapeHtml(p.suite_b)})</span>` : ''}
              <div style="color:var(--ink-mute);font-size:9px;letter-spacing:.18em;text-transform:uppercase;margin-top:2px">${p.match_type} match · ${p.confidence}</div>
            </div>
            <button onclick="mergeDupe(this, ${p.id_a}, ${p.id_b})" style="background:transparent;border:1px solid var(--green);color:var(--green);padding:5px 10px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">✓ Merge</button>
            <button onclick="dismissDupe(this, ${p.id_a}, ${p.id_b})" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:5px 10px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer">✕ Dismiss</button>
          </div>
        `).join('')}
      </div>` : '';
    // Pitch-type heatmap (iter 64) — horizontal bar showing top types in this building
    const PT_COLORS = {
      'law-office':         '#c4b07b',
      'medical-clinic':     '#7ab8a3',
      'dental-office':      '#9bc4a3',
      'real-estate':        '#7b9bc4',
      'restaurant':         '#c47b7b',
      'salon-spa':          '#c47ba6',
      'beauty-spa':         '#c47ba6',
      'retail-storefront':  '#c4a07b',
      'office-tenant':      '#a3a3c4',
      'fitness-studio':     '#a3c47b',
      'hospitality':        '#c4a05c',
      'building-neighbor':  '#d4b683',
      '(no pitch)':         '#3a3530',
    };
    const ptRows = (ptData.rows || []).slice(0, 6);
    const ptTotal = (ptData.rows || []).reduce((a, x) => a + Number(x.n), 0);
    const heatmapHtml = ptTotal > 0 ? `
      <div style="margin-bottom:14px">
        <div style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-bottom:6px">Vertical mix · ${ptTotal} tenants</div>
        <div style="display:flex;height:14px;border:1px solid var(--rule);overflow:hidden">
          ${(ptData.rows || []).map(r => `
            <div style="background:${PT_COLORS[r.pitch_type] || '#555'};width:${100 * Number(r.n) / ptTotal}%" title="${escapeHtml(r.pitch_type)}: ${r.n}"></div>
          `).join('')}
        </div>
        <div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px;font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-mute)">
          ${ptRows.map(r => `
            <span><span style="display:inline-block;width:10px;height:10px;background:${PT_COLORS[r.pitch_type] || '#555'};margin-right:4px;vertical-align:middle"></span>${escapeHtml(r.pitch_type.replace(/-/g, ' '))} <b style="color:var(--ink);font-family:var(--mono)">${r.n}</b></span>
          `).join('')}
        </div>
      </div>` : '';
    roster.innerHTML = heatmapHtml + dupePairsHtml + `
      <table>
        <thead><tr>
          <th>Suite</th><th>Tenant</th><th>NAICS</th><th>Status</th><th>Channel</th><th>Sent</th><th>Reply</th><th></th>
        </tr></thead>
        <tbody>
        ${rows.map(r => `
          <tr>
            <td class="suite">${escapeHtml(r.suite || '')}</td>
            <td class="name">${escapeHtml(r.name)}${dupeBadge(r.business_id)}${r.news_count > 0 ? `<span class="pill" style="color:var(--metal-glow);border-color:var(--metal-glow);font-size:7px;margin-left:6px" title="${r.news_count} scraped news/blog post${r.news_count === 1 ? '' : 's'}"><a href="/business/${r.business_id}/news" style="color:inherit;text-decoration:none">📰 ${r.news_count}</a></span>` : ''}</td>
            <td class="naics">${escapeHtml((r.naics || '').slice(0, 50))}</td>
            <td>${r.pitch_id
              ? `<span class="pill ${r.status}">${escapeHtml(r.status)}</span>`
              : `<span class="pill no-pitch">no pitch</span>`}</td>
            <td><span class="pill">${escapeHtml(r.outreach_channel || '—')}</span></td>
            <td>${r.sent_at ? new Date(r.sent_at).toLocaleDateString() : '—'}</td>
            <td>${r.replied_at ? '✓ ' + new Date(r.replied_at).toLocaleDateString() : '—'}</td>
            <td>${r.pitch_id ? `<a href="/pitches.html?id=${r.pitch_id}" target="_blank">Detail ↗</a>` : ''}</td>
          </tr>
        `).join('')}
        </tbody>
      </table>`;
  } catch (e) {
    roster.innerHTML = `<div class="empty">Error: ${e.message}</div>`;
  }
}

document.getElementById('min-tenants').addEventListener('change', load);
document.getElementById('sort').addEventListener('change', load);
document.getElementById('filter').addEventListener('input', applyFilter);

async function mergeDupe(btn, idA, idB) {
  if (!confirm(`Merge these two businesses into one pitch? This collapses the duplicate — only the primary business stays in the roster.`)) return;
  btn.disabled = true; btn.textContent = '⟳';
  try {
    const r = await fetch('/api/buildings/dupes/merge', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id_a: idA, id_b: idB })
    });
    if (!r.ok) throw new Error(await r.text());
    const d = await r.json();
    btn.closest('div[style*="grid-template-columns"]').style.opacity = '0.4';
    btn.closest('div[style*="grid-template-columns"]').innerHTML = `<div style="color:var(--green);font-size:11px;letter-spacing:.18em;text-transform:uppercase">✓ Merged · primary = #${d.primary} · secondary #${d.secondary} hidden</div>`;
    loadDupeTotal();
  } catch (e) {
    btn.disabled = false; btn.textContent = '✓ Merge';
    alert('Merge failed: ' + e.message);
  }
}

async function dismissDupe(btn, idA, idB) {
  btn.disabled = true; btn.textContent = '⟳';
  try {
    const r = await fetch('/api/buildings/dupes/dismiss', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id_a: idA, id_b: idB })
    });
    if (!r.ok) throw new Error(await r.text());
    btn.closest('div[style*="grid-template-columns"]').style.opacity = '0.4';
    btn.closest('div[style*="grid-template-columns"]').innerHTML = `<div style="color:var(--ink-mute);font-size:11px;letter-spacing:.18em;text-transform:uppercase">✕ Dismissed · false positive</div>`;
    loadDupeTotal();
  } catch (e) {
    btn.disabled = false; btn.textContent = '✕ Dismiss';
    alert('Dismiss failed: ' + e.message);
  }
}

async function bulkMerge(minConf, bldg, dryRun) {
  const scope = bldg ? `building ${bldg}` : 'corridor-wide';
  if (!dryRun && !confirm(`Sweep-merge every pair with confidence ≥ ${minConf} (${scope})? Lower-confidence pairs are untouched. This is one-way unless you reset merged_into manually.`)) return;
  try {
    const r = await fetch('/api/buildings/dupes/bulk-merge', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ min_confidence: minConf, bldg_address: bldg, dry_run: dryRun })
    });
    if (!r.ok) throw new Error(await r.text());
    const d = await r.json();
    if (dryRun) {
      alert(`Preview: ${d.eligible_pairs} pair${d.eligible_pairs === 1 ? '' : 's'} eligible at ≥${minConf} (${d.scope}). No changes made. Click "Sweep merge" to apply.`);
    } else {
      alert(`Merged ${d.merged} pair${d.merged === 1 ? '' : 's'} · ${d.skipped_chains} skipped (chained dupes — re-run to catch them)`);
      // Reload to reflect new state
      document.querySelectorAll('.roster').forEach(r => r.dataset.loaded = '0');
      document.querySelectorAll('.bldg.open').forEach(b => b.classList.remove('open'));
      load();
      loadDupeTotal();
    }
  } catch (e) {
    alert('Bulk merge failed: ' + e.message);
  }
}

async function loadDupeTotal() {
  try {
    const r = await fetch('/api/buildings/dupe-stats');
    if (r.ok) {
      const d = await r.json();
      const el = document.getElementById('s-dupes');
      if (el) el.textContent = (d.pairs || 0).toLocaleString();
    }
  } catch {}
}

async function refreshDupes() {
  const el = document.getElementById('s-dupes');
  if (el) el.textContent = '⟳';
  try {
    const r = await fetch('/api/buildings/refresh-dupes');
    const d = await r.json();
    if (el) el.textContent = (d.pairs || 0).toLocaleString();
    // Reset all loaded rosters so they re-fetch dupes
    document.querySelectorAll('.roster').forEach(r => r.dataset.loaded = '0');
    document.querySelectorAll('.bldg.open').forEach(b => b.classList.remove('open'));
  } catch (e) {
    if (el) el.textContent = '!';
  }
}

loadDupeTotal();

// ─── Priority "walk here next" section (iter 61) ─────────────────────
async function loadPriority() {
  const radius = document.getElementById('prio-radius').value;
  const list = document.getElementById('priority-list');
  list.innerHTML = `<div style="color:var(--ink-mute);padding:14px;font-style:italic">scanning corridor…</div>`;
  try {
    const data = await fetch(`/api/buildings/priority?max_miles=${radius}&min_unpitched=2&limit=15`).then(r => r.json());
    const rows = data.rows || [];
    document.getElementById('prio-summary').innerHTML =
      `<b style="color:var(--metal-glow);font-family:var(--mono)">${data.count}</b> bldgs · <b style="color:var(--metal);font-family:var(--mono)">${data.total_unpitched_in_view}</b> unpitched doors`;
    if (!rows.length) {
      list.innerHTML = `<div style="color:var(--ink-mute);padding:14px;font-style:italic">No unwalked walkable buildings — all priority targets done. Widen the radius?</div>`;
      return;
    }
    list.innerHTML = rows.map((b, i) => {
      const isDW = (b.bldg_address || '').startsWith('15442 ');
      return `
      <div style="background:var(--noir-rise);border:1px solid var(--rule);${isDW ? 'border-color:var(--metal-glow);' : ''}padding:12px 14px;display:grid;grid-template-rows:auto auto auto;gap:6px">
        <div style="display:flex;align-items:baseline;gap:10px">
          <span style="font-family:var(--serif);font-style:italic;font-size:24px;color:var(--metal);min-width:30px">${String(i+1).padStart(2,'0')}</span>
          <a href="/buildings.html?bldg=${encodeURIComponent(b.bldg_address)}" style="font-family:var(--serif);font-size:18px;color:var(--ink);text-decoration:none">${escapeHtml(b.bldg_address)}${isDW ? ' ⚡' : ''}</a>
        </div>
        <div style="display:flex;gap:14px;font-size:10px;letter-spacing:.15em;color:var(--ink-mute);text-transform:uppercase">
          <span>${b.city || ''}</span>
          <span style="color:var(--metal-glow);font-family:var(--mono)">${b.dw_miles} mi</span>
          <span><b style="color:var(--metal);font-family:var(--mono);font-size:13px">${b.unpitched}</b> unpitched</span>
          <span style="color:var(--ink-mute)">/ ${b.tenants_total} total</span>
        </div>
        <div style="display:flex;gap:8px;font-size:9px;letter-spacing:.18em;text-transform:uppercase">
          <span style="color:var(--metal-glow);font-family:var(--mono);background:rgba(212,182,131,0.06);padding:2px 8px">score ${b.priority_score}</span>
          <a href="/walk-route.html?focus=${b.bldg_address.split(' ')[0]}" target="_blank" style="color:var(--metal);text-decoration:none;padding:2px 8px;border:1px solid var(--rule)">🚶 walk</a>
          <a href="/buildings.html?bldg=${encodeURIComponent(b.bldg_address)}" target="_blank" style="color:var(--metal);text-decoration:none;padding:2px 8px;border:1px solid var(--rule)">🏢 roster</a>
        </div>
      </div>`;
    }).join('');
  } catch (e) {
    list.innerHTML = `<div style="color:var(--red);padding:14px">priority load failed: ${e.message}</div>`;
  }
}
document.getElementById('prio-radius').addEventListener('change', loadPriority);
loadPriority();

// Deep-link via ?bldg= URL param: pre-filter + auto-expand the matching card
(async () => {
  const urlBldg = new URLSearchParams(location.search).get('bldg');
  if (urlBldg) {
    document.getElementById('filter').value = urlBldg;
    document.getElementById('min-tenants').value = '2';
    await load();
    // Expand the first matching card
    const target = document.querySelector(`.bldg[data-addr="${encodeURIComponent(urlBldg)}"] .addr`);
    if (target) {
      target.scrollIntoView({ behavior: 'smooth', block: 'center' });
      toggleBldg(target);
    }
  } else {
    load();
  }
})();
</script>
</body>
</html>