← back to Ventura Corridor

public/index.html

502 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<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>
<title>Ventura Blvd Corridor — local viewer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<style>
  :root {
    --noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724;
    --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a;
    --metal: #b89968; --metal-glow: #d4b683; --metal-deep: #8a7044;
    --serif: "Cormorant Garamond", Georgia, serif;
    --sans: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
  }
  *, *::before, *::after { box-sizing: border-box; }
  html, body { margin: 0; height: 100%; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
  header { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: 16px 22px; background: linear-gradient(180deg, rgba(10,10,12,.92), rgba(10,10,12,0)); backdrop-filter: blur(8px); display: flex; align-items: baseline; justify-content: space-between; gap: 24px; }
  h1 { font-family: var(--serif); font-weight: 300; font-size: 22px; margin: 0; letter-spacing: -0.005em; }
  h1 em { font-style: italic; color: var(--metal); }
  .stats { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); display: flex; gap: 22px; flex-wrap: wrap; }
  .stats b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: 0.01em; text-transform: none; margin-right: 6px; font-variant-numeric: tabular-nums; }

  #map { position: absolute; inset: 0; }
  .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) saturate(0.6) brightness(0.92) contrast(0.92); }

  /* Grid search */
  .gs-wrap { position: fixed; top: 56px; left: 22px; z-index: 950; width: clamp(280px, 32vw, 420px); }
  .gs-input { width: 100%; padding: 14px 18px; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink); font-family: var(--sans); font-size: 14px; font-weight: 300; letter-spacing: 0.005em; outline: none; transition: border-color 200ms ease; }
  .gs-input::placeholder { color: var(--ink-mute); font-style: italic; font-family: var(--serif); }
  .gs-input:focus { border-color: var(--metal); box-shadow: 0 0 0 3px rgba(184,153,104,0.12); }
  .gs-results { background: var(--noir-rise); border: 1px solid var(--rule); border-top: none; max-height: 60vh; overflow-y: auto; display: none; }
  .gs-results.show { display: block; }
  .gs-empty { padding: 18px; font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 13px; }
  .gs-grid { display: grid; grid-template-columns: 1fr; gap: 0; }
  /* Search cells — clean editorial: name on top, meta beneath. No dots
     (the color-dot pattern is a DW-micro-site convention, not universal). */
  .gs-cell { padding: 14px 18px; border-bottom: 1px solid var(--rule); cursor: pointer; transition: background 160ms ease, border-color 160ms ease; display: flex; flex-direction: column; gap: 4px; }
  .gs-cell:last-child { border-bottom: none; }
  .gs-cell:hover { background: rgba(184,153,104,0.04); border-bottom-color: var(--metal); }
  .gs-cell-name { font-family: var(--serif); font-weight: 400; color: var(--ink); font-size: 15px; line-height: 1.18; letter-spacing: -0.005em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .gs-cell-meta { font-size: 11px; color: var(--ink-mute); letter-spacing: 0.04em; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .gs-cell-meta .pin { color: var(--metal); font-style: italic; font-family: var(--serif); font-size: 12px; margin-right: 4px; }
  .gs-cell-meta .domain { color: var(--metal); font-family: 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 10px; letter-spacing: 0; text-transform: lowercase; margin-left: 6px; opacity: 0.85; }
  .gs-count { padding: 8px 18px; font-size: 9px; letter-spacing: 0.28em; text-transform: uppercase; color: var(--ink-mute); border-bottom: 1px solid var(--rule); background: rgba(0,0,0,0.2); }

  /* Detail panel */
  #panel { position: fixed; top: 56px; right: 0; bottom: 0; width: 380px; max-width: 90vw; z-index: 900; background: var(--noir-rise); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 360ms cubic-bezier(.16,.84,.3,1); overflow-y: auto; }
  #panel.show { transform: none; }
  #panel .close { position: absolute; top: 14px; right: 16px; background: transparent; color: var(--ink-mute); border: 1px solid var(--rule); padding: 6px 10px; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; cursor: pointer; }
  #panel .close:hover { color: var(--metal); border-color: var(--metal); }
  .pn-body { padding: 28px 28px 40px; }
  .pn-eyebrow { font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 10px; }
  .pn-name { font-family: var(--serif); font-weight: 400; font-size: 24px; line-height: 1.18; color: var(--ink); margin: 0 0 8px; letter-spacing: -0.005em; }
  .pn-meta { font-size: 12px; color: var(--ink-mute); line-height: 1.6; margin: 0 0 18px; }
  .pn-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--rule); font-size: 13px; }
  .pn-row .k { color: var(--ink-mute); letter-spacing: 0.04em; }
  .pn-row .v { color: var(--ink); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; }
  .pn-row .v.metal { color: var(--metal); }
  .pn-website { display: block; margin-top: 18px; padding: 14px 18px; border: 1px solid var(--metal); color: var(--metal-glow); font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 500; text-align: center; text-decoration: none; transition: background 200ms ease, color 200ms ease; }
  .pn-website:hover { background: var(--metal); color: var(--noir); }
  .pn-empty { font-style: italic; color: var(--ink-mute); font-family: var(--serif); font-size: 14px; }

  .leaflet-popup-content-wrapper { background: var(--noir-rise); color: var(--ink); border-radius: 0; border: 1px solid var(--rule); }
  .leaflet-popup-content { margin: 12px 16px; font-family: var(--sans); font-size: 12px; }
  .leaflet-popup-content b { font-family: var(--serif); font-weight: 400; font-size: 14px; color: var(--metal-glow); display: block; margin-bottom: 4px; }
  .leaflet-popup-tip { background: var(--noir-rise); border: 1px solid var(--rule); }

  /* Live build-status strip — bottom-right */
  #build { position: fixed; bottom: 16px; right: 16px; z-index: 950; background: var(--noir-rise); border: 1px solid var(--rule); padding: 14px 18px; max-width: 360px; font-size: 11px; line-height: 1.5; color: var(--ink-soft); transition: opacity 220ms ease; }
  #build.refreshing { opacity: 0.6; }
  #build .b-eyebrow { font-size: 9px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 8px; display: flex; justify-content: space-between; align-items: center; }
  #build .b-eyebrow .ts { color: var(--ink-mute); letter-spacing: .12em; font-style: italic; font-family: var(--serif); text-transform: none; font-size: 10px; }
  #build .b-grid { display: grid; grid-template-columns: 1fr auto; gap: 4px 14px; font-variant-numeric: tabular-nums; }
  #build .b-grid b { font-family: var(--serif); font-style: italic; font-weight: 400; color: var(--metal); font-size: 13px; text-align: right; letter-spacing: 0.005em; }
  #build .b-grid span { color: var(--ink-mute); }
  #build .b-divider { height: 1px; background: var(--rule); margin: 10px 0 8px; }
  #build .b-tier { display: flex; gap: 8px; margin-top: 6px; }
  #build .b-tier .t { flex: 1; padding: 6px 4px; text-align: center; border: 1px solid var(--rule); }
  #build .b-tier .t b { display: block; font-family: var(--serif); font-style: italic; font-size: 14px; font-weight: 400; }
  #build .b-tier .t.A { color: var(--metal-glow); border-color: var(--metal); }
  #build .b-tier .t.B { color: var(--metal); }
  #build .b-tier .t.C { color: #a07a40; }
  #build .b-tier .t.D { color: #5a4220; opacity: 0.85; }
  #build .b-tier .t span { display: block; font-size: 8px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
  #build a.b-3d { display: block; margin-top: 12px; padding: 8px 12px; border: 1px solid var(--metal); color: var(--metal-glow); text-align: center; font-size: 10px; letter-spacing: .22em; text-transform: uppercase; text-decoration: none; transition: background 220ms ease, color 220ms ease; }
  #build a.b-3d:hover { background: var(--metal); color: var(--noir); }
</style>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">
  </head>
<body>
<header>
  <h1>Ventura <em>Boulevard</em> · corridor view <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:12px;vertical-align:middle"></span></h1>
  <div class="stats" id="stats">
    <span><b id="s-total">—</b> on corridor</span>
    <span><b id="s-site">—</b> with website</span>
    <span><b id="s-scored">—</b> scored</span>
  </div>
</header>

<div class="gs-wrap">
  <input type="search" class="gs-input" id="gs-input" placeholder="Search Ventura Blvd businesses…" aria-label="Search businesses" autocomplete="off">
  <div class="gs-results" id="gs-results">
    <div class="gs-empty">Type to search the corridor.</div>
  </div>
</div>

<div id="map"></div>

<div id="build" aria-live="polite">
  <p class="b-eyebrow"><span>Build status</span><span class="ts" id="b-ts">—</span></p>
  <div class="b-grid">
    <span>Businesses on corridor</span><b id="b-businesses">—</b>
    <span>With website</span><b id="b-with-site">—</b>
    <span>Front-page audited</span><b id="b-audited">—</b>
    <span>SEO scored</span><b id="b-scored">—</b>
    <span>Ownership classified</span><b id="b-enriched">—</b>
    <span>With LinkedIn contact</span><b id="b-owner">—</b>
    <span><a href="/news.html" style="color:inherit;text-decoration:none;border-bottom:1px dotted var(--metal)">News articles</a></span><b id="b-news"><a href="/news.html" style="color:var(--metal-glow);text-decoration:none">—</a></b>
    <span>News biz</span><b id="b-news-biz">—</b>
    <span>Fresh ‹7d</span><b id="b-news-7d">—</b>
    <span><a href="http://127.0.0.1:9920/" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;border-bottom:1px dotted var(--metal)">Ideas ✓</a></span><b id="b-ideas">—</b>
    <span style="color:#c47b7b">Ideas ✗</span><b id="b-ideas-rej" style="color:#c47b7b">—</b>
  </div>
  <div class="b-divider"></div>
  <div class="b-fleet" id="b-fleet" style="font-family:var(--mono);font-size:10px;letter-spacing:.14em;color:var(--ink-mute);text-transform:uppercase;display:flex;gap:18px;align-items:center;flex-wrap:wrap">
    <span style="color:var(--metal)">Fleet</span>
    <span id="fleet-mac1">Mac1 ·</span>
    <span id="fleet-mac2">Mac2 ·</span>
  </div>
  <div class="b-divider"></div>
  <div class="b-tier">
    <div class="t A"><b id="b-A">—</b><span>A · 80+</span></div>
    <div class="t B"><b id="b-B">—</b><span>B · 60+</span></div>
    <div class="t C"><b id="b-C">—</b><span>C · 40+</span></div>
    <div class="t D"><b id="b-D">—</b><span>D · &lt;40</span></div>
  </div>
  <a class="b-3d" href="/3d.html">Three.js corridor ↗</a>
  <a class="b-3d" href="/wall.html" style="margin-top:6px">The wall — every front page ↗</a>
  <a class="b-3d" href="/signals.html" style="margin-top:6px">Ad signals — who's paying for ads ↗</a>
  <a class="b-3d" href="/dw-radar.html" style="margin-top:6px">DW radar — interior + furniture + design ↗</a>
  <a class="b-3d" href="/timeline.html" style="margin-top:6px">Timeline — films &amp; archival photos ↗</a>
  <a class="b-3d" href="/pantone.html" style="margin-top:6px">Pantone — the boulevard's color ↗</a>
  <a class="b-3d" href="/headlines.html" style="margin-top:6px">Voices — every business's H1 ↗</a>
  <a class="b-3d" href="/tongues.html" style="margin-top:6px">Tongues — H1s in geographic position ↗</a>
  <a class="b-3d" href="/chains.html" style="margin-top:6px">Chains — the parking-economy story ↗</a>
  <a class="b-3d" href="/atlas.html" style="margin-top:6px">Atlas — corridor at a glance ↗</a>
  <a class="b-3d" href="/eulogy.html" style="margin-top:6px">Eulogy — websites that no longer answer ↗</a>
  <a class="b-3d" href="/marketplace.html" style="margin-top:6px;border-color:var(--metal-glow);color:var(--metal-glow)"><b>Marketplace</b> · local board ↗</a>
  <a class="b-3d" href="/today.html" style="margin-top:6px;border-color:var(--metal-glow);color:var(--metal-glow)"><b>Today</b> · daily action plan ↗</a>
  <a class="b-3d" href="/pitches.html" style="margin-top:6px;border-color:var(--metal);color:var(--metal)">DW pitch pipeline (admin) ↗</a>
  <a class="b-3d" href="/linkedin.html" style="margin-top:6px;border-color:var(--metal);color:var(--metal)">LinkedIn agent (admin) ↗</a>

  <!-- Voices of the boulevard — random Sharp/Exceptional headlines -->
  <div id="voices-tease" style="margin-top:24px;padding-top:18px;border-top:1px solid var(--rule);display:none">
    <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
      <div style="font-size:9px;letter-spacing:.26em;text-transform:uppercase;color:var(--metal)">hear the boulevard</div>
      <button id="voices-shuffle" type="button" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:3px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;cursor:pointer;font-family:inherit">↻ shuffle</button>
    </div>
    <div id="voices-list" style="display:flex;flex-direction:column;gap:12px"></div>
  </div>
</div>

<aside id="panel">
  <button class="close" aria-label="Close panel" onclick="document.getElementById('panel').classList.remove('show')"><span aria-hidden="true">close ×</span></button>
  <div class="pn-body" id="panel-body">
    <p class="pn-empty">Click a marker to see business details.</p>
  </div>
</aside>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
const map = L.map('map', { zoomControl: true }).setView([34.155, -118.495], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);

async function loadStats() {
  try {
    const r = await fetch('/api/stats').then(r => r.json());
    document.getElementById('s-total').textContent = (r.businesses_corridor || 0).toLocaleString();
    document.getElementById('s-site').textContent = (r.businesses_with_site || 0).toLocaleString();
    document.getElementById('s-scored').textContent = (r.scored_total || 0).toLocaleString();
  } catch (e) { /* no data yet */ }
}

function tierColor(score) {
  if (score == null) return '#5a4220';
  if (score >= 90) return '#d4b683';
  if (score >= 70) return '#b89968';
  if (score >= 50) return '#a07a40';
  return '#5a4220';
}

// Pull just the host out of a URL — never expose path/query in the search row.
// Defensive: returns '' on bad input rather than throwing.
function hostOnly(url) {
  try {
    const u = new URL(/^https?:\/\//.test(url) ? url : 'https://' + url);
    return u.hostname.replace(/^www\./, '');
  } catch { return ''; }
}

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

// Cached dataset for grid-search filtering (loaded once on page open)
let DATASET = [];
const MARKERS = new Map(); // business id → L.circleMarker

async function loadBusinesses() {
  try {
    // Pull both rails in one query. OSM rows = audited (visible bigger).
    // BTRC rows = registered-but-not-yet-audited (smaller, dimmer).
    const r = await fetch('/api/businesses?limit=20000').then(r => r.json());
    if (!r.rows || !r.rows.length) return;
    DATASET = r.rows;
    const audited = L.featureGroup().addTo(map);
    const registered = L.featureGroup().addTo(map);
    for (const b of r.rows) {
      if (b.lat == null || b.lng == null) continue;
      const isOsm = b.source === 'osm';
      const m = L.circleMarker([b.lat, b.lng], {
        radius: isOsm ? 5 : 2.5,
        color: isOsm ? tierColor(b.seo_score) : '#6b6359',
        weight: isOsm ? 1 : 0.5,
        fillOpacity: isOsm ? 0.85 : 0.45,
      });
      m.on('click', () => showPanel(b.id));
      m.bindTooltip(b.name || '(no name)', { direction: 'top', opacity: 0.9 });
      (isOsm ? audited : registered).addLayer(m);
      MARKERS.set(b.id, m);
    }
    map.fitBounds(audited.getBounds(), { padding: [40, 40] });
  } catch (e) { console.error(e); }
}

// ─── Grid search — live as-you-type filter over loaded dataset ─────────
const gsInput = document.getElementById('gs-input');
const gsResults = document.getElementById('gs-results');
let gsDebounce;

function gsRender(rows, q) {
  if (!q) {
    gsResults.classList.remove('show');
    return;
  }
  if (!rows.length) {
    gsResults.classList.add('show');
    gsResults.innerHTML = `<div class="gs-empty">No businesses match "${q}".</div>`;
    return;
  }
  // Each result shows the business's OWN domain only — never cross-link.
  const cells = rows.slice(0, 60).map(b => {
    const block = b.corridor_block ? `№ ${b.corridor_block}` : '';
    const meta = [b.category, b.city, block].filter(Boolean).join(' · ');
    const domain = b.website ? hostOnly(b.website) : '';
    return `
      <div class="gs-cell" data-id="${b.id}">
        <span class="gs-cell-name">${escapeHtml(b.name || '(no name)')}${domain ? '<span class="domain">' + escapeHtml(domain) + '</span>' : ''}</span>
        <span class="gs-cell-meta">${escapeHtml(meta)}</span>
      </div>`;
  }).join('');
  gsResults.classList.add('show');
  gsResults.innerHTML = `
    <div class="gs-count">${rows.length} match${rows.length === 1 ? '' : 'es'}${rows.length > 60 ? ' · top 60 shown' : ''}</div>
    <div class="gs-grid">${cells}</div>`;
  gsResults.querySelectorAll('.gs-cell').forEach(el => {
    el.addEventListener('click', () => {
      const id = parseInt(el.dataset.id, 10);
      const b = DATASET.find(x => x.id === id);
      if (b && b.lat != null) map.flyTo([b.lat, b.lng], 17, { duration: 0.6 });
      const m = MARKERS.get(id);
      if (m) m.openPopup();
      showPanel(id);
    });
  });
}

function gsSearch(q) {
  q = q.trim().toLowerCase();
  if (!q) { gsRender([], ''); return; }
  const matches = DATASET.filter(b => {
    if ((b.name || '').toLowerCase().includes(q)) return true;
    if ((b.category || '').toLowerCase().includes(q)) return true;
    if ((b.address || '').toLowerCase().includes(q)) return true;
    if ((b.city || '').toLowerCase().includes(q)) return true;
    return false;
  });
  gsRender(matches, q);
}

gsInput.addEventListener('input', e => {
  clearTimeout(gsDebounce);
  gsDebounce = setTimeout(() => gsSearch(e.target.value), 150);
});
gsInput.addEventListener('focus', e => { if (e.target.value) gsSearch(e.target.value); });
document.addEventListener('keydown', e => {
  if (e.key === 'Escape') { gsInput.value = ''; gsRender([], ''); gsInput.blur(); }
});
// Click outside the search wrapper to dismiss results
document.addEventListener('click', e => {
  if (!e.target.closest('.gs-wrap')) gsResults.classList.remove('show');
});

async function showPanel(id) {
  const panel = document.getElementById('panel');
  const body = document.getElementById('panel-body');
  body.innerHTML = '<p class="pn-empty">Loading…</p>';
  panel.classList.add('show');
  try {
    const r = await fetch('/api/businesses/' + id).then(r => r.json());
    const b = r.business;
    const a = r.latest_audit;
    const s = r.latest_score;
    const websiteHtml = b.website
      ? `<a class="pn-website" href="${b.website}" target="_blank" rel="noopener">Visit website ↗</a>`
      : '<p class="pn-empty" style="margin-top:12px">No website on record.</p>';
    const shotUrl = a && a.screenshot_path ? '/' + a.screenshot_path : null;
    const shotHtml = shotUrl
      ? `<a href="${b.website || '#'}" target="_blank" rel="noopener" style="display:block;margin-bottom:14px;border:1px solid var(--rule);overflow:hidden;line-height:0"><img src="${shotUrl}" alt="${b.name} front-page screenshot" style="width:100%;height:auto;display:block;transition:opacity 220ms ease" onload="this.style.opacity=1" onerror="this.style.display='none'"></a>`
      : '';
    const ownerLine = r.enrichment ? `<div class="pn-row"><span class="k">Ownership</span><span class="v metal">${r.enrichment.ownership_class}${r.enrichment.parent_brand ? ' · ' + r.enrichment.parent_brand : ''}</span></div>` : '';
    // News rail — surfaces what was scraped from this business's own site.
    const newsItems = r.news || [];
    const newsStats = r.news_stats || { fresh_7d: 0, fresh_30d: 0, last_at: null };
    // Recency lozenge in the panel header — visible at-a-glance signal of
    // an active publisher. "Hot" if 3+ in 7 days, "Fresh" if any in 7 days,
    // "Stale" if any in 30 days but none recent. No badge if all news older.
    const recencyBadge = (() => {
      if (newsStats.fresh_7d >= 3) {
        return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:#0a0a0c;background:var(--metal-glow);border-radius:2px" title="${newsStats.fresh_7d} articles in the last 7 days">🔥 Hot</span>`;
      }
      if (newsStats.fresh_7d >= 1) {
        return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--metal);border:1px solid var(--metal)" title="${newsStats.fresh_7d} articles in the last 7 days">📰 Fresh</span>`;
      }
      if (newsStats.fresh_30d >= 1) {
        return `<span style="display:inline-block;margin:0 0 0 8px;padding:2px 8px;font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute);border:1px solid var(--rule)" title="${newsStats.fresh_30d} articles in the last 30 days">Stale</span>`;
      }
      return '';
    })();
    // Defensive: source_url comes from scraped HTML on third-party sites.
    // Validate scheme (http/https only — no javascript:/data:) and HTML-escape
    // all attribute values before interpolating into href. Caught by codex-2way
    // round-1 PROSECUTOR audit on tick 41's news rail commit.
    const safeUrl = (raw) => {
      try {
        const u = new URL(String(raw || ''));
        if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#';
        return u.href.replace(/[<>"' &]/g, c => ({'<':'%3C','>':'%3E','"':'%22',"'":'%27',' ':'%20','&':'&amp;'}[c]));
      } catch { return '#'; }
    };
    const escAttr = (s) => String(s || '').replace(/[<>"'&]/g, c => ({'<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;','&':'&amp;'}[c]));
    const newsBlock = newsItems.length ? (() => {
      const items = newsItems.slice(0, 4).map(n => {
        const dt = n.published_guess || n.fetched_at;
        const dateStr = dt ? new Date(dt).toLocaleDateString('en-US', { month:'short', day:'numeric' }) : '';
        const title = (n.title || '(untitled)').slice(0, 90);
        const safeTitle = escAttr(title);
        const href = safeUrl(n.source_url);
        return `<a href="${href}" target="_blank" rel="noopener noreferrer" style="display:block;padding:6px 0;border-bottom:1px dotted var(--rule);text-decoration:none;color:var(--ink)"><div style="font-size:12px;line-height:1.35">${safeTitle}</div><div style="font-size:9px;color:var(--ink-mute);font-family:'JetBrains Mono',monospace;letter-spacing:.16em;margin-top:2px;text-transform:uppercase">${escAttr(dateStr)}</div></a>`;
      }).join('');
      return `
      <div style="margin:14px 0 0;padding:10px 12px;background:rgba(184,153,104,0.08);border:1px solid var(--rule)">
        <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
          <div style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal)">📰 News from their site · ${newsItems.length}</div>
          <a href="/business/${id}/news" style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal-glow);text-decoration:none;border-bottom:1px dotted var(--metal-glow)">all →</a>
        </div>
        ${items}
      </div>`;
    })() : '';
    const contactLines = (r.contacts || []).map(c => {
      const platform = (c.notes === 'instagram' ? 'Instagram' :
                        c.notes === 'tiktok' ? 'TikTok' :
                        c.role === 'social_handle' ? 'Social' : c.role);
      const handle = c.person_name || c.person_linkedin || '';
      const valueHtml = c.source_url
        ? `<a href="${c.source_url}" target="_blank" rel="noopener" class="metal" style="text-decoration:none">${handle}</a>`
        : handle;
      return `<div class="pn-row"><span class="k">${platform}</span><span class="v">${valueHtml}</span></div>`;
    }).join('');
    body.innerHTML = `
      ${shotHtml}
      <p class="pn-eyebrow">${b.category || 'Uncategorized'}</p>
      <h3 class="pn-name">${b.name}${recencyBadge}${b.source !== 'la_btrc' && b.has_btrc_match === false ? ' <span style="display:inline-block;background:#fee2e2;border:1px solid #d33;color:#900;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:6px;vertical-align:middle">⚠ POSSIBLY NO BUSINESS LICENSE</span>' : ''}</h3>
      <p class="pn-meta">${[b.address, b.city, b.zip].filter(Boolean).join(' · ')}</p>
      ${ownerLine}
      <div class="pn-row"><span class="k">Block</span><span class="v">${b.corridor_block ?? '—'}</span></div>
      <div class="pn-row"><span class="k">Phone</span><span class="v">${b.phone || '—'}</span></div>
      <div class="pn-row"><span class="k">SEO score</span><span class="v metal">${s ? s.total : '— not scored'}</span></div>
      <div class="pn-row"><span class="k">Tier</span><span class="v metal">${s ? s.tier : '—'}</span></div>
      <div class="pn-row"><span class="k">Rank in category</span><span class="v">${s && s.category_rank ? `${s.category_rank} / ${s.category_n}` : '—'}</span></div>
      <div class="pn-row"><span class="k">Last audit</span><span class="v">${a ? new Date(a.audited_at).toLocaleDateString() : '— not audited'}</span></div>
      ${contactLines}
      ${newsBlock}
      ${websiteHtml}
    `;
  } catch (e) {
    body.innerHTML = '<p class="pn-empty">Failed to load.</p>';
  }
}

async function loadCorridor() {
  try {
    const r = await fetch('/api/segments').then(r => r.json());
    if (!r.rows || !r.rows.length) return;
    // Build a single polyline through every segment endpoint.
    const points = [];
    for (const s of r.rows) {
      if (!points.length) points.push([s.west_lat, s.west_lng]);
      points.push([s.east_lat, s.east_lng]);
    }
    L.polyline(points, { color: '#b89968', weight: 3, opacity: 0.55, dashArray: null }).addTo(map);
    L.polyline(points, { color: '#d4b683', weight: 1, opacity: 0.85 }).addTo(map);
  } catch (e) { /* no data yet */ }
}

async function loadBuildStatus() {
  const el = document.getElementById('build');
  if (el) el.classList.add('refreshing');
  try {
    const r = await fetch('/api/build-status').then(r => r.json());
    const c = r.counts || {};
    const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = (v ?? 0).toLocaleString(); };
    set('b-businesses', c.businesses);
    set('b-with-site', c.with_site);
    set('b-audited', c.audited);
    set('b-scored', c.scored);
    set('b-enriched', c.enriched);
    set('b-owner', c.with_owner_contact);
    const setNews = (id, v) => { const e = document.getElementById(id); if (e) { const a = e.querySelector('a'); if (a) a.textContent = (v ?? 0).toLocaleString(); else e.textContent = (v ?? 0).toLocaleString(); } };
    setNews('b-news', c.news_total);
    setNews('b-news-biz', c.news_biz);
    setNews('b-news-7d', c.news_fresh_7d);
    setNews('b-ideas', c.ideas_accepted);
    setNews('b-ideas-rej', c.ideas_rejected);
    // Fleet: pull Mac1/Mac2 state from /api/fleet (10s cache server-side)
    try {
      const fleet = await fetch('/api/fleet').then(r => r.json());
      const fmtMac = (m, port) => {
        if (!m || !m.ok) return `<a href="http://${port}" target="_blank" style="color:#c47b7b;border-bottom:1px dotted #c47b7b;text-decoration:none">${m?.label || port} · offline</a>`;
        const model = m.ollama_models[0];
        const modelStr = model ? `${model.name} ${model.pinned ? '⚓' : ''}` : 'idle';
        const cpu = m.cpu.toFixed(0), ram = m.ram_pct.toFixed(0);
        const dashUrl = port === '100.94.103.98:9930' ? 'http://100.94.103.98:9930' : 'http://localhost:9931';
        return `<a href="${dashUrl}" target="_blank" style="color:inherit;text-decoration:none"><span style="color:var(--metal)">${escapeHtml(m.label)}</span> · <span style="color:var(--ink)">${escapeHtml(modelStr)}</span> · cpu <b style="color:var(--metal-glow)">${cpu}%</b> · ram <b style="color:var(--metal-glow)">${ram}%</b></a>`;
      };
      const m1El = document.getElementById('fleet-mac1');
      const m2El = document.getElementById('fleet-mac2');
      if (m1El) m1El.innerHTML = fmtMac(fleet.macs[0], '100.94.103.98:9930');
      if (m2El) m2El.innerHTML = fmtMac(fleet.macs[1], 'localhost:9931');
    } catch(e) { /* fleet bridge unavailable */ }
    set('b-A', c.tier_a);
    set('b-B', c.tier_b);
    set('b-C', c.tier_c);
    set('b-D', c.tier_d);
    const ts = new Date(r.ts || Date.now());
    document.getElementById('b-ts').textContent = ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
  } catch {}
  if (el) el.classList.remove('refreshing');
}

loadStats();
loadCorridor();
loadBusinesses();
loadBuildStatus();
// Live refresh every 5s while ingest/crawl/score is running.
setInterval(loadBuildStatus, 5000);

// Voices of the boulevard — random sharp/exceptional headlines
async function loadVoices() {
  try {
    const r = await fetch('/api/quotes?limit=3&min_voice=3').then(r => r.json());
    const rows = r.rows || [];
    if (!rows.length) return;
    const tease = document.getElementById('voices-tease');
    const list = document.getElementById('voices-list');
    list.innerHTML = rows.map(q => {
      const isExc = q.headline_voice === 4;
      const tag = q.headline_voice_label;
      const tagColor = isExc ? 'var(--metal-glow)' : 'var(--metal)';
      const safeH = (q.headline || '').replace(/[<>]/g, '');
      const safeN = (q.name || '').replace(/[<>]/g, '');
      return `<div style="display:block">
        <div style="font-size:8px;letter-spacing:.26em;text-transform:uppercase;color:${tagColor};margin-bottom:3px">${tag}</div>
        <div style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:${isExc?17:15}px;line-height:1.25;color:var(--ink);margin-bottom:2px">${safeH}</div>
        <div style="font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)">— ${safeN}${q.city ? ' · ' + q.city : ''}</div>
      </div>`;
    }).join('');
    tease.style.display = '';
  } catch {}
}
loadVoices();
document.getElementById('voices-shuffle')?.addEventListener('click', loadVoices);
</script>
</body>
</html>