← back to SDCC Stories

public/js/app.js

1062 lines

/**
 * SDCC Story Engine — Frontend
 * Safe DOM rendering (no raw innerHTML with user data)
 */
const AUTH = 'Basic ' + btoa('admin:DWSecure2024!');
const headers = { 'Authorization': AUTH, 'Content-Type': 'application/json' };
let currentPage = 1;
let searchTimer = null;

// ─── Safe text escaping ───
function esc(s) {
  if (s === null || s === undefined) return '';
  const d = document.createElement('div');
  d.textContent = String(s);
  return d.textContent;
}

// Safe render: uses template element to parse trusted HTML with escaped dynamic values
function render(el, html) {
  const t = document.createElement('template');
  t.setHTMLUnsafe(html);
  el.replaceChildren(t.content.cloneNode(true));
}

// ─── API helper ───
async function api(path, opts = {}) {
  const res = await fetch(path, { headers, ...opts });
  return res.json();
}

// ─── Tab switching ───
function switchTab(name) {
  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
  document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
  if (name === 'candidates') loadCandidates();
  if (name === 'stories') loadStories();
  if (name === 'calendar') loadCalendar();
  if (name === 'heatmap') loadHeatmap();
  if (name === 'ideas') loadIdeas();
  if (name === 'settings') loadSettings();
}

// ─── Stats Bar ───
async function loadStats() {
  const s = await api('/api/stats');
  const bar = document.getElementById('stats-bar');
  const items = [
    { label: 'Total', value: s.total, color: '' },
    { label: 'Tier A', value: s.tierA, color: 'var(--lane-a)' },
    { label: 'Tier B', value: s.tierB, color: 'var(--lane-b)' },
    { label: 'Tier C', value: s.tierC, color: 'var(--lane-c)' },
    { label: 'W/ Story', value: s.withStory, color: '' },
    { label: 'Consent', value: s.withConsent, color: 'var(--success)' },
    { label: 'Press Ready', value: s.pressReady, color: 'var(--warning)' },
    { label: 'Stories Made', value: s.totalStories, color: '' },
    { label: 'Scheduled', value: s.scheduled, color: 'var(--lane-b)' },
    { label: 'Posted', value: s.posted, color: 'var(--success)' }
  ];
  bar.replaceChildren();
  for (const item of items) {
    const card = document.createElement('div');
    card.className = 'stat-card';
    const val = document.createElement('div');
    val.className = 'stat-value';
    val.textContent = item.value || 0;
    if (item.color) val.style.color = item.color;
    const lbl = document.createElement('div');
    lbl.className = 'stat-label';
    lbl.textContent = item.label;
    card.append(val, lbl);
    // Click tier stats to filter candidates
    if (['Tier A', 'Tier B', 'Tier C'].includes(item.label)) {
      card.style.cursor = 'pointer';
      card.addEventListener('click', () => {
        document.getElementById('filter-tier').value = item.label.split(' ')[1];
        switchTab('candidates');
      });
    }
    bar.appendChild(card);
  }
}

// ─── Candidates List ───
async function loadCandidates() {
  const tier = document.getElementById('filter-tier').value;
  const search = document.getElementById('filter-search').value;
  const params = new URLSearchParams({ page: currentPage, limit: 50 });
  if (tier) params.set('tier', tier);
  if (search) params.set('search', search);

  const data = await api('/api/candidates?' + params);
  document.getElementById('candidate-count').textContent =
    data.total + ' candidates' + (tier ? ' (Tier ' + tier + ')' : '') + (search ? ' matching "' + search + '"' : '');

  const list = document.getElementById('candidate-list');
  list.replaceChildren();

  for (const c of data.candidates) {
    const card = document.createElement('div');
    card.className = 'candidate-card';
    card.addEventListener('click', () => openCandidate(c.id));

    // Score circle
    const score = document.createElement('div');
    score.className = 'score-circle';
    score.textContent = c.candidate_score;
    score.style.background = c.tier === 'A' ? 'var(--lane-a)' : c.tier === 'B' ? 'var(--lane-b)' : c.tier === 'C' ? 'var(--lane-c)' : 'var(--text-muted)';

    // Name + tier
    const nameEl = document.createElement('div');
    const nameSpan = document.createElement('span');
    nameSpan.className = 'candidate-name';
    nameSpan.textContent = (c.first_name || 'Anonymous') + (c.last_name ? ' ' + c.last_name.charAt(0) + '.' : '');
    const badge = document.createElement('span');
    badge.className = 'badge badge-' + c.tier.toLowerCase();
    badge.textContent = c.tier;
    badge.style.marginLeft = '6px';
    nameEl.append(nameSpan, badge);

    // Amount
    const amt = document.createElement('div');
    amt.className = 'candidate-amount';
    amt.textContent = c.amount_display || c.amount_owed_raw || '—';

    // Snippet
    const snippet = document.createElement('div');
    snippet.className = 'candidate-snippet';
    snippet.textContent = (c.emotional_impact || c.story || '').slice(0, 120);

    // Location
    const loc = document.createElement('div');
    loc.className = 'candidate-meta';
    loc.textContent = (c.zip_code || '') + (c.district_code ? ' | ' + c.district_code : '') + (c.representative_name ? ' (' + c.representative_name + ')' : '');

    // Consent
    const consent = document.createElement('div');
    consent.className = 'candidate-meta';
    const isConsent = (c.sharing_consent || '').toLowerCase().includes('yes');
    consent.textContent = isConsent ? 'Consented' : 'No consent';
    consent.style.color = isConsent ? 'var(--success)' : 'var(--text-muted)';

    card.append(score, nameEl, amt, snippet, loc, consent);
    list.appendChild(card);
  }

  // Pagination
  const totalPages = Math.ceil(data.total / data.limit);
  const pag = document.getElementById('pagination');
  pag.replaceChildren();
  for (let p = 1; p <= Math.min(totalPages, 20); p++) {
    const btn = document.createElement('button');
    btn.textContent = p;
    if (p === currentPage) btn.classList.add('active');
    btn.addEventListener('click', () => { currentPage = p; loadCandidates(); });
    pag.appendChild(btn);
  }
}

function debounceSearch() {
  clearTimeout(searchTimer);
  searchTimer = setTimeout(() => { currentPage = 1; loadCandidates(); }, 300);
}

// ─── Candidate Detail Modal ───
async function openCandidate(id) {
  const data = await api('/api/candidates/' + id);
  const c = data.candidate;
  const fd = data.fullData || {};
  const modal = document.getElementById('candidate-modal');
  const detail = document.getElementById('candidate-detail');
  detail.replaceChildren();

  // Header
  const h2 = document.createElement('h2');
  h2.textContent = (c.first_name || 'Anonymous') + ' ' + (c.last_name || '');
  h2.style.marginBottom = '4px';
  const sub = document.createElement('div');
  sub.style.cssText = 'display:flex;gap:8px;margin-bottom:16px;align-items:center';
  const tierBadge = document.createElement('span');
  tierBadge.className = 'badge badge-' + (c.tier || 'd').toLowerCase();
  tierBadge.textContent = 'Tier ' + (c.tier || 'D') + ' — Score ' + (c.candidate_score || 0);
  const amtSpan = document.createElement('span');
  amtSpan.style.cssText = 'color:var(--secondary);font-weight:700;font-size:16px';
  amtSpan.textContent = c.amount_display || c.amount_owed_raw || '—';
  sub.append(tierBadge, amtSpan);
  detail.append(h2, sub);

  // Sections
  const sections = [
    { title: 'Demographics', rows: [
      ['Age', c.age], ['ZIP Code', c.zip_code], ['Gender', c.gender],
      ['Race/Ethnicity', c.race_ethnicity], ['Marital Status', c.marital_status],
      ['Household Size', c.household_size], ['Employment', c.employment],
      ['Education', c.education], ['Income Type', c.income_type],
      ['Income Range', c.income_range], ['Voter', c.is_voter]
    ]},
    { title: 'Loan Details', rows: [
      ['Amount Owed (Raw)', c.amount_owed_raw], ['Amount Display', c.amount_display],
      ['Amount Low', c.amount_low], ['Amount High', c.amount_high],
      ['Loan Types', c.loan_types_raw], ['Repayment Plan', c.repayment_plan],
      ['Monthly Payment', c.monthly_payment], ['First Loan Year', c.first_loan_year]
    ]},
    { title: 'Story Essentials (P/Q/R)', rows: [
      ['Emotional Impact (P)', c.emotional_impact],
      ['Can\'t Afford (Q)', c.affordability_impacts_raw],
      ['Would Spend On (R)', c.alt_spending]
    ]},
    { title: 'Story Content', rows: [
      ['Their Story', c.story], ['Key Quote', c.key_quote],
      ['Story Category', c.story_category], ['Story Strength', c.story_strength]
    ]},
    { title: 'Consent & Civic', rows: [
      ['Sharing Consent', c.sharing_consent], ['Press Ready', c.press_ready],
      ['Registered Voter', c.is_voter], ['Platform Fit', c.platform_fit]
    ]},
    { title: 'Congressional District', rows: [
      ['District', c.district_code], ['Representative', c.representative_name],
      ['Party', c.representative_party]
    ]}
  ];

  for (const section of sections) {
    const sec = document.createElement('div');
    sec.className = 'detail-section';
    const h3 = document.createElement('h3');
    h3.textContent = section.title;
    sec.appendChild(h3);
    for (const [label, value] of section.rows) {
      if (!value && value !== 0) continue;
      const row = document.createElement('div');
      row.className = 'detail-row';
      const lbl = document.createElement('div');
      lbl.className = 'detail-label';
      lbl.textContent = label;
      const val = document.createElement('div');
      val.className = 'detail-value';
      val.textContent = String(value);
      // Highlight long text fields
      if (String(value).length > 100) {
        val.style.cssText = 'font-size:12px;line-height:1.5;white-space:pre-wrap';
      }
      row.append(lbl, val);
      sec.appendChild(row);
    }
    detail.appendChild(sec);
  }

  // Key quote highlight
  if (c.key_quote) {
    const quoteBox = document.createElement('div');
    quoteBox.style.cssText = 'font-style:italic;color:var(--text-secondary);padding:12px;border-left:3px solid var(--lane-a);margin:12px 0;font-size:15px';
    quoteBox.textContent = '"' + c.key_quote + '"';
    detail.insertBefore(quoteBox, detail.children[2]);
  }

  // Action buttons
  const actions = document.createElement('div');
  actions.style.cssText = 'display:flex;gap:8px;margin-top:16px;flex-wrap:wrap';

  const genBtn = document.createElement('button');
  genBtn.className = 'btn btn-action';
  genBtn.textContent = 'Generate Story';
  genBtn.addEventListener('click', () => generateStoryForCandidate(c.id));

  actions.appendChild(genBtn);

  // Show existing stories
  if (data.stories && data.stories.length > 0) {
    const storyHeader = document.createElement('h3');
    storyHeader.textContent = 'Generated Stories (' + data.stories.length + ')';
    storyHeader.style.cssText = 'color:var(--secondary);margin-top:16px;border-bottom:1px solid var(--border);padding-bottom:4px';
    detail.appendChild(storyHeader);

    for (const st of data.stories) {
      const stCard = document.createElement('div');
      stCard.className = 'story-card';
      stCard.style.marginTop = '8px';

      const oneLiner = document.createElement('div');
      oneLiner.style.cssText = 'font-weight:700;font-size:14px;margin-bottom:6px';
      oneLiner.textContent = st.story_one_liner || '';

      const igCap = document.createElement('div');
      igCap.style.cssText = 'font-size:12px;color:var(--text-secondary);white-space:pre-wrap;margin-bottom:6px';
      igCap.textContent = st.instagram_caption || '';

      const xPost = document.createElement('div');
      xPost.style.cssText = 'font-size:12px;color:var(--lane-b);margin-bottom:6px';
      xPost.textContent = 'X: ' + (st.x_post || '');

      const statusBadge = document.createElement('span');
      statusBadge.className = 'badge badge-' + (st.status || 'draft');
      statusBadge.textContent = st.status || 'draft';

      const stActions = document.createElement('div');
      stActions.style.cssText = 'display:flex;gap:6px;margin-top:8px';

      const gfxBtn = document.createElement('button');
      gfxBtn.className = 'btn btn-sm btn-action';
      gfxBtn.textContent = 'Generate Graphic';
      gfxBtn.addEventListener('click', (e) => { e.stopPropagation(); generateGraphicForStory(st.id); });

      const lwBtn = document.createElement('button');
      lwBtn.className = 'btn btn-sm btn-policy';
      lwBtn.textContent = 'Lawmaker Graphic';
      lwBtn.addEventListener('click', (e) => { e.stopPropagation(); generateLawmakerGraphicForStory(st.id); });

      stActions.append(gfxBtn, lwBtn);

      if (st.image_path) {
        const img = document.createElement('img');
        img.src = st.image_path;
        img.className = 'story-image';
        img.style.maxWidth = '300px';
        stCard.append(oneLiner, igCap, xPost, statusBadge, stActions, img);
      } else {
        stCard.append(oneLiner, igCap, xPost, statusBadge, stActions);
      }
      detail.appendChild(stCard);
    }
  }

  detail.appendChild(actions);
  modal.classList.add('open');
}

function closeModal() {
  document.getElementById('candidate-modal').classList.remove('open');
}

// Close modal on backdrop click
document.getElementById('candidate-modal').addEventListener('click', (e) => {
  if (e.target === e.currentTarget) closeModal();
});

// ─── Story Generation ───
async function generateStoryForCandidate(candidateId) {
  const btn = event.target;
  btn.textContent = 'Generating...';
  btn.disabled = true;
  try {
    const result = await api('/api/stories/generate', { method: 'POST', body: JSON.stringify({ candidate_id: candidateId }) });
    if (result.id) {
      openCandidate(candidateId); // Refresh modal
    } else {
      alert('Generation failed: ' + (result.error || 'Unknown error'));
    }
  } catch (e) {
    alert('Error: ' + e.message);
  } finally {
    btn.textContent = 'Generate Story';
    btn.disabled = false;
  }
}

async function generateGraphicForStory(storyId) {
  const result = await api('/api/stories/' + storyId + '/generate-graphic', { method: 'POST', body: '{}' });
  if (result.success) {
    loadStories();
    // Refresh the current modal if open
    const detail = document.getElementById('candidate-detail');
    if (detail.children.length > 0) {
      // Re-open the same candidate
    }
  }
}

async function generateLawmakerGraphicForStory(storyId) {
  const result = await api('/api/stories/' + storyId + '/generate-lawmaker-graphic', { method: 'POST', body: '{}' });
  if (result.success) {
    loadStories();
  }
}

// ─── Stories Tab (expandable) ───
async function loadStories() {
  const filter = document.getElementById('story-filter').value;
  const params = filter ? '?status=' + filter : '';
  const stories = await api('/api/stories' + params);
  const list = document.getElementById('story-list');
  list.replaceChildren();

  if (stories.length === 0) {
    const empty = document.createElement('div');
    empty.style.cssText = 'color:var(--text-muted);text-align:center;padding:40px';
    empty.textContent = 'No stories yet. Go to Candidates tab and generate stories for borrowers who consented to social media sharing.';
    list.appendChild(empty);
    return;
  }

  for (const s of stories) {
    const card = document.createElement('div');
    card.className = 'story-card';
    card.style.cursor = 'pointer';
    card.dataset.storyId = s.id;

    // Header row
    const header = document.createElement('div');
    header.className = 'story-header';

    const title = document.createElement('div');
    const nameSpan = document.createElement('strong');
    nameSpan.textContent = (s.first_name || 'Unknown') + (s.last_name ? ' ' + s.last_name.charAt(0) + '.' : '');
    const amtSpan = document.createElement('span');
    amtSpan.style.cssText = 'color:var(--secondary);margin-left:8px';
    amtSpan.textContent = s.amount_owed_raw || '';
    const tierBdg = document.createElement('span');
    tierBdg.className = 'badge badge-' + (s.tier || 'd').toLowerCase();
    tierBdg.textContent = s.tier || '?';
    tierBdg.style.marginLeft = '8px';
    title.append(nameSpan, amtSpan, tierBdg);

    const statusBdg = document.createElement('span');
    statusBdg.className = 'badge badge-' + (s.status || 'draft');
    statusBdg.textContent = s.status || 'draft';
    header.append(title, statusBdg);

    // One-liner (always visible)
    const oneLiner = document.createElement('div');
    oneLiner.style.cssText = 'font-size:13px;color:var(--text-secondary);margin-top:6px';
    oneLiner.textContent = s.story_one_liner || '(no story text)';

    // Expandable detail section
    const detail = document.createElement('div');
    detail.style.cssText = 'display:none;margin-top:12px;border-top:1px solid var(--border);padding-top:12px';

    // Borrower details
    const borrowerSection = document.createElement('div');
    borrowerSection.style.marginBottom = '12px';
    const bTitle = document.createElement('h4');
    bTitle.textContent = 'Borrower Details';
    bTitle.style.cssText = 'color:var(--secondary);font-size:13px;margin-bottom:6px';
    borrowerSection.appendChild(bTitle);

    const details = [
      ['Name', (s.first_name || '') + ' ' + (s.last_name || '')],
      ['ZIP', s.zip_code || ''],
      ['Amount', s.amount_owed_raw || ''],
      ['District', s.district_code || ''],
      ['Representative', s.representative_name || ''],
      ['Emotional Impact', s.emotional_impact || ''],
      ['Can\'t Afford', s.affordability_impacts_raw || ''],
      ['Would Spend On', s.alt_spending || '']
    ];
    for (const [lbl, val] of details) {
      if (!val) continue;
      const row = document.createElement('div');
      row.className = 'detail-row';
      const l = document.createElement('div');
      l.className = 'detail-label';
      l.textContent = lbl;
      const v = document.createElement('div');
      v.className = 'detail-value';
      v.textContent = val;
      if (val.length > 80) v.style.cssText = 'font-size:12px;white-space:pre-wrap';
      row.append(l, v);
      borrowerSection.appendChild(row);
    }
    detail.appendChild(borrowerSection);

    // Instagram caption
    if (s.instagram_caption) {
      const igSec = document.createElement('div');
      igSec.style.marginBottom = '8px';
      const igLabel = document.createElement('div');
      igLabel.style.cssText = 'font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px';
      igLabel.textContent = 'Instagram Caption';
      const igText = document.createElement('div');
      igText.style.cssText = 'font-size:12px;color:var(--text-secondary);white-space:pre-wrap';
      igText.textContent = s.instagram_caption;
      igSec.append(igLabel, igText);
      detail.appendChild(igSec);
    }

    // X post
    if (s.x_post) {
      const xSec = document.createElement('div');
      xSec.style.marginBottom = '8px';
      const xLabel = document.createElement('div');
      xLabel.style.cssText = 'font-size:11px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px';
      xLabel.textContent = 'X Post';
      const xText = document.createElement('div');
      xText.style.cssText = 'font-size:12px;color:var(--lane-b)';
      xText.textContent = s.x_post;
      xSec.append(xLabel, xText);
      detail.appendChild(xSec);
    }

    // Image
    if (s.image_path) {
      const img = document.createElement('img');
      img.src = s.image_path;
      img.className = 'story-image';
      img.style.maxWidth = '300px';
      detail.appendChild(img);
    }

    // Action buttons
    const actions = document.createElement('div');
    actions.className = 'story-actions';
    const gfxBtn = document.createElement('button');
    gfxBtn.className = 'btn btn-sm btn-action';
    gfxBtn.textContent = 'Generate Graphic';
    gfxBtn.addEventListener('click', (e) => { e.stopPropagation(); generateGraphicForStory(s.id); });
    const lwBtn = document.createElement('button');
    lwBtn.className = 'btn btn-sm btn-policy';
    lwBtn.textContent = 'Lawmaker Graphic';
    lwBtn.addEventListener('click', (e) => { e.stopPropagation(); generateLawmakerGraphicForStory(s.id); });
    const pubBtn = document.createElement('button');
    pubBtn.className = 'btn btn-sm btn-resource';
    pubBtn.textContent = 'Publish to IG';
    pubBtn.addEventListener('click', (e) => { e.stopPropagation(); publishStory(s.id); });
    actions.append(gfxBtn, lwBtn, pubBtn);
    detail.appendChild(actions);

    // Toggle expand
    card.addEventListener('click', () => {
      const open = detail.style.display !== 'none';
      detail.style.display = open ? 'none' : 'block';
    });

    card.append(header, oneLiner, detail);
    list.appendChild(card);
  }
}

async function publishStory(id) {
  if (!confirm('Publish this story to Instagram?')) return;
  const result = await api('/api/stories/' + id + '/publish', { method: 'POST', body: '{}' });
  if (result.success) {
    loadStories();
  } else {
    alert('Publish failed: ' + (result.error || 'Unknown'));
  }
}

// ─── Calendar ───
async function loadCalendar() {
  const stories = await api('/api/calendar');
  const grid = document.getElementById('calendar-grid');
  grid.replaceChildren();

  // Day headers
  const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  for (const d of days) {
    const hdr = document.createElement('div');
    hdr.style.cssText = 'text-align:center;font-weight:700;color:var(--text-muted);font-size:12px;padding:4px';
    hdr.textContent = d;
    grid.appendChild(hdr);
  }

  // Group stories by date
  const byDate = {};
  for (const s of stories) {
    const date = (s.scheduled_at || '').split('T')[0];
    if (!byDate[date]) byDate[date] = [];
    byDate[date].push(s);
  }

  // Generate 28-day grid starting from today
  const today = new Date();
  const startDay = new Date(today);
  startDay.setDate(today.getDate() - today.getDay()); // Start on Sunday

  for (let i = 0; i < 28; i++) {
    const d = new Date(startDay);
    d.setDate(startDay.getDate() + i);
    const dateStr = d.toISOString().split('T')[0];

    const cell = document.createElement('div');
    cell.className = 'calendar-day';
    if (dateStr === today.toISOString().split('T')[0]) cell.style.borderColor = 'var(--primary)';

    const dayHeader = document.createElement('div');
    dayHeader.className = 'day-header';
    dayHeader.textContent = d.getDate() + ' ' + d.toLocaleDateString('en-US', { month: 'short' });
    cell.appendChild(dayHeader);

    const dayStories = byDate[dateStr] || [];
    for (const s of dayStories) {
      const slot = document.createElement('div');
      slot.className = 'calendar-slot ' + (s.platform || 'instagram');
      const time = (s.scheduled_at || '').includes('09:') ? '9am' : '6pm';
      slot.textContent = time + ' ' + (s.first_name || '?') + ' (' + (s.platform || 'ig') + ')';
      slot.style.cursor = 'pointer';
      slot.addEventListener('click', () => {
        if (s.candidate_id) openCandidate(s.candidate_id);
      });
      cell.appendChild(slot);
    }
    grid.appendChild(cell);
  }
}

async function autoFillCalendar() {
  if (!confirm('Auto-fill 7 days with Tier A/B candidate stories (2x daily)?')) return;
  const btn = event.target;
  btn.textContent = 'Filling...';
  btn.disabled = true;
  try {
    const result = await api('/api/calendar/auto-fill', { method: 'POST', body: JSON.stringify({ days: 7 }) });
    alert('Scheduled ' + (result.scheduled || 0) + ' stories');
    loadCalendar();
  } catch (e) {
    alert('Error: ' + e.message);
  } finally {
    btn.textContent = 'Auto-Fill 7 Days';
    btn.disabled = false;
  }
}

// ─── Heat Map ───
async function loadHeatmap() {
  const data = await api('/api/heatmap');
  const container = document.getElementById('heatmap-container');
  container.replaceChildren();

  // Story Classification Tree — clickable branches
  const treeSection = document.createElement('div');
  treeSection.style.cssText = 'width:100%;margin-bottom:20px;grid-column:1/-1';
  const treeTitle = document.createElement('h3');
  treeTitle.textContent = 'Story Classification';
  treeTitle.style.cssText = 'color:var(--secondary);margin-bottom:12px';
  treeSection.appendChild(treeTitle);

  const tiers = data.byTier || [];
  const tierColors = { A: 'var(--lane-a)', B: 'var(--lane-b)', C: 'var(--lane-c)', D: 'var(--text-muted)' };
  const tierLabels = { A: 'Press + Social + Lawmakers', B: 'Social + Lawmakers', C: 'Social Only', D: 'Needs Enrichment' };
  const tierTotal = tiers.reduce((sum, t) => sum + t.count, 0) || 1;

  for (const t of tiers) {
    const branch = document.createElement('div');
    branch.style.cssText = 'display:flex;align-items:center;gap:12px;padding:8px 12px;margin-bottom:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;transition:all 0.15s';
    branch.addEventListener('mouseenter', () => { branch.style.borderColor = tierColors[t.tier] || 'var(--border)'; });
    branch.addEventListener('mouseleave', () => { branch.style.borderColor = 'var(--border)'; });
    // Click to filter candidates by this tier
    branch.addEventListener('click', () => {
      document.getElementById('filter-tier').value = t.tier;
      currentPage = 1;
      switchTab('candidates');
    });

    const badge = document.createElement('span');
    badge.className = 'badge badge-' + (t.tier || 'd').toLowerCase();
    badge.textContent = 'Tier ' + t.tier;
    badge.style.minWidth = '50px';

    const label = document.createElement('span');
    label.style.cssText = 'color:var(--text-secondary);font-size:13px;flex:1';
    label.textContent = tierLabels[t.tier] || '';

    const count = document.createElement('span');
    count.style.cssText = 'font-weight:700;font-size:16px;min-width:60px;text-align:right';
    count.textContent = t.count;

    const barOuter = document.createElement('div');
    barOuter.style.cssText = 'flex:2;height:8px;background:var(--surface-el);border-radius:4px;overflow:hidden';
    const barInner = document.createElement('div');
    barInner.style.cssText = 'height:100%;border-radius:4px;background:' + (tierColors[t.tier] || 'var(--text-muted)');
    barInner.style.width = Math.round((t.count / tierTotal) * 100) + '%';
    barOuter.appendChild(barInner);

    branch.append(badge, label, count, barOuter);
    treeSection.appendChild(branch);
  }
  container.appendChild(treeSection);

  // State breakdown
  if (data.byState && data.byState.length > 0) {
    const stateTitle = document.createElement('h3');
    stateTitle.textContent = 'By State';
    stateTitle.style.cssText = 'color:var(--secondary);margin-bottom:8px;grid-column:1/-1';
    container.appendChild(stateTitle);

    const maxCount = Math.max(...data.byState.map(s => s.count));
    for (const s of data.byState.slice(0, 30)) {
      const card = document.createElement('div');
      card.className = 'heatmap-card';

      const state = document.createElement('div');
      state.className = 'district';
      state.textContent = s.state;

      const cnt = document.createElement('div');
      cnt.className = 'count';
      cnt.textContent = s.count;

      const avgScore = document.createElement('div');
      avgScore.style.cssText = 'font-size:11px;color:var(--text-muted)';
      avgScore.textContent = 'Avg score: ' + Math.round(s.avg_score || 0);

      const bar = document.createElement('div');
      bar.className = 'bar';
      bar.style.background = 'linear-gradient(90deg, var(--lane-a) 0%, var(--lane-b) 100%)';
      bar.style.width = Math.round((s.count / maxCount) * 100) + '%';

      card.append(state, cnt, avgScore, bar);
      container.appendChild(card);
    }
  }

  // District breakdown
  if (data.byDistrict && data.byDistrict.length > 0) {
    const distTitle = document.createElement('h3');
    distTitle.textContent = 'By Congressional District';
    distTitle.style.cssText = 'color:var(--secondary);margin-bottom:8px;grid-column:1/-1;margin-top:16px';
    container.appendChild(distTitle);

    for (const d of data.byDistrict.slice(0, 20)) {
      const card = document.createElement('div');
      card.className = 'heatmap-card';
      card.style.cursor = 'pointer';
      card.addEventListener('click', () => {
        document.getElementById('filter-search').value = d.district_code;
        document.getElementById('filter-tier').value = '';
        currentPage = 1;
        switchTab('candidates');
      });

      const dist = document.createElement('div');
      dist.className = 'district';
      dist.textContent = d.district_code;

      const rep = document.createElement('div');
      rep.className = 'rep-name';
      rep.textContent = (d.representative_name || '—') + ' (' + (d.representative_party || '?') + ')';

      const cnt = document.createElement('div');
      cnt.className = 'count';
      cnt.textContent = d.count;

      const tierBdg = document.createElement('span');
      tierBdg.className = 'badge badge-' + (d.tier || 'd').toLowerCase();
      tierBdg.textContent = 'Tier ' + (d.tier || '?');

      card.append(dist, rep, cnt, tierBdg);
      container.appendChild(card);
    }
  }
}

// ─── Content Ideas ───
async function loadIdeas() {
  const ideas = await api('/api/ideas');
  const grid = document.getElementById('ideas-grid');
  grid.replaceChildren();

  const laneColors = { action: 'var(--lane-a)', policy: 'var(--lane-b)', resources: 'var(--lane-c)' };
  for (const idea of ideas) {
    const card = document.createElement('div');
    card.className = 'idea-card';

    const title = document.createElement('div');
    title.className = 'idea-title';
    title.textContent = idea.title;

    const desc = document.createElement('div');
    desc.className = 'idea-desc';
    desc.textContent = idea.description;

    const meta = document.createElement('div');
    meta.className = 'idea-meta';
    const laneBdg = document.createElement('span');
    laneBdg.className = 'badge';
    laneBdg.textContent = idea.lane;
    laneBdg.style.background = laneColors[idea.lane] || 'var(--text-muted)';
    laneBdg.style.color = 'white';
    const platBdg = document.createElement('span');
    platBdg.className = 'badge badge-draft';
    platBdg.textContent = idea.platform;
    meta.append(laneBdg, platBdg);

    card.append(title, desc, meta);
    grid.appendChild(card);
  }
}

// ─── Settings ───
async function loadSettings() {
  const settings = await api('/api/settings');
  const form = document.getElementById('settings-form');
  form.replaceChildren();

  const title = document.createElement('h3');
  title.textContent = 'Instagram API Settings';
  title.style.cssText = 'color:var(--secondary);margin-bottom:16px';
  form.appendChild(title);

  const fields = [
    { key: 'ig_app_id', label: 'Meta App ID', type: 'text' },
    { key: 'ig_app_secret', label: 'App Secret', type: 'password' },
    { key: 'ig_access_token', label: 'Access Token (auto-filled on connect)', type: 'text' },
    { key: 'ig_business_account_id', label: 'IG Business Account ID', type: 'text' },
    { key: 'app_url', label: 'App URL (for OAuth redirect)', type: 'text' },
    { key: 'post_time_morning', label: 'Morning Post Time', type: 'text' },
    { key: 'post_time_evening', label: 'Evening Post Time', type: 'text' },
    { key: 'auto_schedule', label: 'Auto-Post Scheduled Stories', type: 'text' }
  ];

  const inputs = {};
  for (const f of fields) {
    const row = document.createElement('div');
    row.className = 'setting-row';
    const label = document.createElement('label');
    label.textContent = f.label;
    const input = document.createElement('input');
    input.type = f.type;
    input.value = settings[f.key] || '';
    input.placeholder = f.label;
    inputs[f.key] = input;
    row.append(label, input);
    form.appendChild(row);
  }

  const saveBtn = document.createElement('button');
  saveBtn.className = 'btn';
  saveBtn.textContent = 'Save Settings';
  saveBtn.style.marginTop = '12px';
  saveBtn.addEventListener('click', async () => {
    const body = {};
    for (const [key, input] of Object.entries(inputs)) {
      body[key] = input.value;
    }
    await api('/api/settings', { method: 'PUT', body: JSON.stringify(body) });
    alert('Settings saved');
  });
  form.appendChild(saveBtn);
}

// ─── Instagram Connection ───
function connectInstagram() {
  window.location.href = '/auth/instagram';
}

async function checkIGStatus() {
  try {
    const status = await api('/api/instagram/status');
    const el = document.getElementById('ig-status');
    if (status.connected) {
      el.textContent = 'Instagram: Connected';
      el.className = 'badge badge-posted';
    } else {
      el.textContent = 'Instagram: Not Connected';
      el.className = 'badge badge-draft';
    }
  } catch (e) { /* ignore */ }
}

// ─── Refresh data from Google Sheet ───
async function refreshData() {
  if (!confirm('Re-download data from Google Sheet and re-import all candidates?')) return;
  const btn = event.target;
  btn.textContent = 'Refreshing...';
  btn.disabled = true;
  try {
    const result = await api('/api/import/refresh', { method: 'POST', body: '{}' });
    alert('Imported ' + (result.imported || 0) + ' candidates');
    loadStats();
    loadCandidates();
  } catch (e) {
    alert('Error: ' + e.message);
  } finally {
    btn.textContent = 'Refresh from Sheet';
    btn.disabled = false;
  }
}

// ─── Global Search ───
let gsTimer = null;
let gsResults = [];
let gsSelected = 0;

function openGlobalSearch() {
  const panel = document.getElementById('global-search-results');
  if (panel) panel.hidden = false;
}

function closeGlobalSearch() {
  const panel = document.getElementById('global-search-results');
  if (panel) panel.hidden = true;
}

function onGlobalSearchInput(e) {
  const q = (e.target.value || '').trim();
  clearTimeout(gsTimer);
  if (q.length < 2) {
    gsResults = [];
    renderGlobalSearchResults('');
    return;
  }
  gsTimer = setTimeout(() => runGlobalSearch(q), 220);
}

async function runGlobalSearch(q) {
  const panel = document.getElementById('global-search-results');
  if (!panel) return;
  panel.innerHTML = '<div class="gs-empty">Searching…</div>';
  panel.hidden = false;
  try {
    const res = await fetch('/api/search?q=' + encodeURIComponent(q) + '&limit=25', { credentials: 'include' });
    if (!res.ok) {
      panel.innerHTML = '<div class="gs-empty">Search failed (HTTP ' + res.status + ').</div>';
      return;
    }
    const data = await res.json();
    gsResults = data.results || [];
    gsSelected = 0;
    renderGlobalSearchResults(q, data);
  } catch (err) {
    panel.innerHTML = '<div class="gs-empty">Search error: ' + (err.message || err) + '</div>';
  }
}

function renderGlobalSearchResults(q, data) {
  const panel = document.getElementById('global-search-results');
  if (!panel) return;
  if (!q || gsResults.length === 0) {
    if (q && q.length >= 2) {
      panel.innerHTML = '<div class="gs-empty">No results for &ldquo;' + escapeHtml(q) + '&rdquo;.</div>'
        + '<div class="gs-footer"><span><span class="gs-kbd">esc</span> close</span></div>';
      panel.hidden = false;
    } else {
      panel.innerHTML = '';
      panel.hidden = true;
    }
    return;
  }
  // Group by type for sections
  const groups = { candidate: [], story: [], setting: [] };
  for (const r of gsResults) {
    if (groups[r.type]) groups[r.type].push(r);
  }
  const LABELS = { candidate: 'Candidates', story: 'Stories', setting: 'Settings' };
  const ICONS  = { candidate: 'C', story: 'S', setting: '⚙' };
  let html = '';
  let flatIdx = 0;
  for (const type of ['candidate', 'story', 'setting']) {
    const arr = groups[type];
    if (!arr.length) continue;
    const cnt = (data && data.counts && data.counts[type + 's']) || arr.length;
    html += '<div class="gs-section"><div class="gs-section-label">' + LABELS[type] + ' (' + cnt + ')</div>';
    for (const r of arr) {
      const selected = flatIdx === gsSelected ? ' gs-selected' : '';
      html += '<div class="gs-item' + selected + '" data-idx="' + flatIdx + '" data-type="' + type + '" data-id="' + escapeAttr(r.id) + '">'
        + '<div class="gs-item-icon gs-icon-' + type + '">' + ICONS[type] + '</div>'
        + '<div class="gs-item-body">'
        + '<div class="gs-item-title">' + escapeHtml(r.title) + '</div>'
        + (r.subtitle ? '<div class="gs-item-subtitle">' + escapeHtml(r.subtitle) + '</div>' : '')
        + (r.meta     ? '<div class="gs-item-meta">' + escapeHtml(r.meta) + '</div>' : '')
        + '</div></div>';
      flatIdx++;
    }
    html += '</div>';
  }
  html += '<div class="gs-footer">'
       +    '<span><span class="gs-kbd">↑</span> <span class="gs-kbd">↓</span> navigate &nbsp; <span class="gs-kbd">↵</span> open</span>'
       +    '<span><span class="gs-kbd">esc</span> close</span>'
       + '</div>';
  panel.innerHTML = html;
  panel.hidden = false;
  // Wire clicks
  panel.querySelectorAll('.gs-item').forEach(el => {
    el.addEventListener('click', () => {
      const idx = parseInt(el.dataset.idx, 10);
      pickGlobalSearchResult(idx);
    });
    el.addEventListener('mouseenter', () => {
      gsSelected = parseInt(el.dataset.idx, 10);
      highlightGlobalSearchSelection();
    });
  });
}

function highlightGlobalSearchSelection() {
  const panel = document.getElementById('global-search-results');
  if (!panel) return;
  panel.querySelectorAll('.gs-item').forEach(el => {
    el.classList.toggle('gs-selected', parseInt(el.dataset.idx, 10) === gsSelected);
  });
  // Ensure selection visible
  const sel = panel.querySelector('.gs-selected');
  if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' });
}

function onGlobalSearchKeyDown(e) {
  if (e.key === 'Escape') {
    e.target.value = '';
    gsResults = [];
    closeGlobalSearch();
    e.target.blur();
    return;
  }
  if (!gsResults.length) return;
  if (e.key === 'ArrowDown') {
    e.preventDefault();
    gsSelected = Math.min(gsSelected + 1, gsResults.length - 1);
    highlightGlobalSearchSelection();
  } else if (e.key === 'ArrowUp') {
    e.preventDefault();
    gsSelected = Math.max(gsSelected - 1, 0);
    highlightGlobalSearchSelection();
  } else if (e.key === 'Enter') {
    e.preventDefault();
    pickGlobalSearchResult(gsSelected);
  }
}

function pickGlobalSearchResult(idx) {
  const r = gsResults[idx];
  if (!r) return;
  closeGlobalSearch();
  const input = document.getElementById('global-search-input');
  if (input) input.value = '';
  if (r.type === 'candidate') {
    switchTab('candidates');
    // Best-effort: openCandidate exists in this file
    if (typeof openCandidate === 'function') {
      setTimeout(() => openCandidate(r.id), 50);
    }
  } else if (r.type === 'story') {
    switchTab('stories');
    // Try to scroll the matching story card into view
    setTimeout(() => {
      const card = document.querySelector('[data-story-id="' + r.id + '"]');
      if (card) {
        card.scrollIntoView({ behavior: 'smooth', block: 'center' });
        card.style.outline = '2px solid var(--primary)';
        setTimeout(() => { card.style.outline = ''; }, 1500);
      }
    }, 250);
  } else if (r.type === 'setting') {
    switchTab('settings');
  }
}

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

// Close on outside click + ⌘K / Ctrl+K to focus the search input.
document.addEventListener('click', (e) => {
  const wrap = document.querySelector('.global-search-wrap');
  if (wrap && !wrap.contains(e.target)) closeGlobalSearch();
});
document.addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
    e.preventDefault();
    const input = document.getElementById('global-search-input');
    if (input) { input.focus(); input.select(); }
  }
});

// ─── Init ───
document.addEventListener('DOMContentLoaded', () => {
  loadStats();
  loadCandidates();
  checkIGStatus();
});