← back to Rentv Adintel

src/export/executive-viewer.js

645 lines

'use strict';
/**
 * Executive viewer generator — produces the self-contained executive-viewer.html
 * that is bundled inside the Download Everything ZIP (spec §28).
 *
 * Requirements:
 *   - Works by double-click (file:// protocol), NO server required.
 *   - All assets embedded inline: CSS in <style>, JS in <script>, data in
 *     const DATA = {...} JSON blob so file:// can read sibling files.
 *   - Thumbnail images embedded as data: URIs (only EXPORT_ALLOWED assets).
 *   - INTERNAL_EVIDENCE_ONLY images → neutral placeholder, not embedded.
 *   - Private notes and suppressed entities are already excluded upstream
 *     by applyExportRights() before this function is called.
 *   - CA/AZ filters, search (fuzzy text match), verified-only toggle.
 *   - Accessible (ARIA roles, tabindex, visible focus) and printable.
 *   - No external resources (no CDN, no fonts API, no images from http://).
 *
 * @module src/export/executive-viewer
 */

/**
 * Escape a string for safe inline HTML output.
 * @param {*} v
 * @returns {string}
 */
function h(v) {
  if (v === null || v === undefined) return '';
  return String(v)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/**
 * Generate the self-contained executive-viewer.html.
 *
 * @param {Object} payload
 * @param {Object[]} payload.advertisers  - filtered organizations with joined fields
 * @param {Object[]} payload.contacts     - filtered contact_points rows
 * @param {Object[]} payload.adSightings  - filtered ad_sightings rows
 * @param {Object[]} payload.events       - filtered events rows
 * @param {Object[]} payload.eventRels    - filtered event_relationships rows
 * @param {Object[]} payload.prospects    - filtered opportunity_scores rows (joined)
 * @param {Object[]} payload.ga4Summary   - aggregated GA4 daily metrics
 * @param {Map<string,string>} payload.thumbnailDataUris - assetId → data:image/... URI
 * @param {string}   payload.exportDate   - ISO date string YYYY-MM-DD
 * @param {Object}   payload.sourceCoverage - { total, withEvidence, lastUpdated }
 * @returns {string} complete HTML document
 */
function generateExecutiveViewer(payload) {
  const {
    advertisers = [],
    contacts = [],
    adSightings = [],
    events = [],
    eventRels = [],
    prospects = [],
    ga4Summary = [],
    thumbnailDataUris = new Map(),
    exportDate = new Date().toISOString().slice(0, 10),
    sourceCoverage = {},
  } = payload;

  // Pre-index contacts and ad-sightings by org id for O(1) lookup in cards
  const contactsByOrg = new Map();
  for (const c of contacts) {
    if (!contactsByOrg.has(c.organization_id)) contactsByOrg.set(c.organization_id, []);
    contactsByOrg.get(c.organization_id).push(c);
  }

  const sightingsByOrg = new Map();
  for (const s of adSightings) {
    if (!sightingsByOrg.has(s.organization_id)) sightingsByOrg.set(s.organization_id, []);
    sightingsByOrg.get(s.organization_id).push(s);
  }

  const eventRelsByOrg = new Map();
  for (const er of eventRels) {
    if (!eventRelsByOrg.has(er.organization_id)) eventRelsByOrg.set(er.organization_id, []);
    eventRelsByOrg.get(er.organization_id).push(er);
  }

  // Build the inline DATA blob that drives client-side JS
  // Thumbnails already filtered to EXPORT_ALLOWED only
  const dataBlobObj = {
    exportDate,
    sourceCoverage,
    advertisers: advertisers.map((org) => ({
      id: org.id,
      display_name: org.display_name,
      domain: org.domain || null,
      headquarters_state: org.headquarters_state || null,
      headquarters_city: org.headquarters_city || null,
      organization_type: org.organization_type || null,
      advertiser_categories: org.advertiser_categories || [],
      active_status: org.active_status || 'ACTIVE',
      // Computed best relationship status from ad_sightings
      best_status: (() => {
        const sightings = sightingsByOrg.get(org.id) || [];
        const statuses = sightings.map((s) => s.relationship_status);
        const priority = [
          'VERIFIED_ADVERTISER',
          'VERIFIED_CONFERENCE_SPONSOR',
          'VERIFIED_EXHIBITOR',
          'VERIFIED_MEDIA_PARTNER',
          'VERIFIED_CONTENT_PARTNER',
          'PAST_ADVERTISER',
          'LIKELY_PROSPECT',
          'RESEARCH_NEEDED',
          'SPEAKER_OR_PANELIST_ONLY',
          'DISQUALIFIED',
        ];
        for (const p of priority) {
          if (statuses.includes(p)) return p;
        }
        return 'RESEARCH_NEEDED';
      })(),
      thumbnail_asset_id: (() => {
        const sightings = sightingsByOrg.get(org.id) || [];
        for (const s of sightings) {
          if (s.thumbnail_asset_id && thumbnailDataUris.has(s.thumbnail_asset_id)) {
            return s.thumbnail_asset_id;
          }
        }
        return null;
      })(),
      contacts: (contactsByOrg.get(org.id) || []).map((c) => ({
        type: c.type,
        value: c.value,
        explicitly_public: c.explicitly_public,
      })),
      sightings: (sightingsByOrg.get(org.id) || []).map((s) => ({
        relationship_status: s.relationship_status,
        source_page_url: s.source_page_url || null,
        headline: s.headline || null,
        observed_at: s.observed_at || null,
        verification_status: s.verification_status,
      })),
      event_rels: (eventRelsByOrg.get(org.id) || []).map((er) => ({
        relationship_status: er.relationship_status,
        sponsor_level: er.sponsor_level || null,
      })),
    })),
    ga4Summary: ga4Summary.slice(0, 90), // last ~3 months for dashboard
    // Convert Map to object for JSON serialization
    thumbnails: Object.fromEntries(thumbnailDataUris),
  };

  const dataJson = JSON.stringify(dataBlobObj);

  // Status label lookup (mirrors lib/types.js)
  const STATUS_LABELS = {
    VERIFIED_ADVERTISER: 'Verified advertiser',
    VERIFIED_CONFERENCE_SPONSOR: 'Verified conference sponsor',
    VERIFIED_EXHIBITOR: 'Verified exhibitor',
    VERIFIED_MEDIA_PARTNER: 'Verified media partner',
    VERIFIED_CONTENT_PARTNER: 'Content partner',
    SPEAKER_OR_PANELIST_ONLY: 'Speaker / panelist only',
    PAST_ADVERTISER: 'Past advertiser',
    LIKELY_PROSPECT: 'Likely prospect',
    RESEARCH_NEEDED: 'Research needed',
    DISQUALIFIED: 'Disqualified',
  };

  const VERIFIED_STATUSES = new Set([
    'VERIFIED_ADVERTISER',
    'VERIFIED_CONFERENCE_SPONSOR',
    'VERIFIED_EXHIBITOR',
    'VERIFIED_MEDIA_PARTNER',
    'VERIFIED_CONTENT_PARTNER',
  ]);

  const statusLabelsJson = JSON.stringify(STATUS_LABELS);
  const verifiedStatusesJson = JSON.stringify([...VERIFIED_STATUSES]);

  // Stats for the header bar
  const totalAdvertisers = advertisers.length;
  const verifiedCount = advertisers.filter((o) => {
    const sightings = sightingsByOrg.get(o.id) || [];
    return sightings.some((s) => VERIFIED_STATUSES.has(s.relationship_status));
  }).length;
  const caCount = advertisers.filter(
    (o) => (o.headquarters_state || '').toUpperCase() === 'CA'
  ).length;
  const azCount = advertisers.filter(
    (o) => (o.headquarters_state || '').toUpperCase() === 'AZ'
  ).length;

  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RENTV Advertiser Intelligence — offline viewer, data as of ${h(exportDate)}">
<title>RENTV Advertiser Intelligence — ${h(exportDate)}</title>
<style>
/* ========================================================
   RENTV Advertiser Intelligence — Offline Viewer Styles
   Self-contained, printable, accessible.
   ======================================================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --blue:    #1a56db;
  --blue-d:  #1140b0;
  --green:   #0e7a3a;
  --amber:   #9a5800;
  --red:     #b91c1c;
  --gray-1:  #f8fafc;
  --gray-2:  #f1f5f9;
  --gray-3:  #e2e8f0;
  --gray-4:  #cbd5e1;
  --gray-5:  #94a3b8;
  --gray-7:  #334155;
  --gray-9:  #0f172a;
  --radius:  8px;
  --shadow:  0 1px 3px rgba(0,0,0,.12), 0 1px 2px rgba(0,0,0,.08);
  --shadow-md: 0 4px 6px -1px rgba(0,0,0,.1);
  font-size: 16px;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  background: var(--gray-1);
  color: var(--gray-9);
  line-height: 1.5;
  min-height: 100vh;
}

/* Header */
.header {
  background: var(--blue);
  color: #fff;
  padding: 16px 24px;
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
}
.header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; }
.header .badge {
  background: rgba(255,255,255,.18);
  border-radius: 999px;
  font-size: .75rem;
  padding: 2px 10px;
  font-weight: 600;
}
.header .meta { margin-left: auto; font-size: .8rem; opacity: .85; }

/* Stats bar */
.stats-bar {
  background: #fff;
  border-bottom: 1px solid var(--gray-3);
  padding: 12px 24px;
  display: flex;
  gap: 24px;
  flex-wrap: wrap;
  font-size: .85rem;
}
.stat { display: flex; align-items: baseline; gap: 6px; }
.stat .num { font-size: 1.35rem; font-weight: 700; color: var(--blue); }
.stat .lbl { color: var(--gray-7); }

/* Toolbar */
.toolbar {
  background: #fff;
  border-bottom: 1px solid var(--gray-3);
  padding: 12px 24px;
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  align-items: center;
}
.toolbar label { font-size: .85rem; font-weight: 600; color: var(--gray-7); }

#searchInput {
  flex: 1;
  min-width: 200px;
  max-width: 400px;
  border: 2px solid var(--gray-3);
  border-radius: var(--radius);
  padding: 8px 12px;
  font-size: .9rem;
  transition: border-color .15s;
}
#searchInput:focus { outline: none; border-color: var(--blue); }

.filter-btns { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-btn {
  border: 2px solid var(--gray-3);
  background: #fff;
  border-radius: var(--radius);
  padding: 6px 14px;
  font-size: .82rem;
  font-weight: 600;
  cursor: pointer;
  color: var(--gray-7);
  transition: border-color .12s, background .12s, color .12s;
}
.filter-btn:hover  { border-color: var(--blue); color: var(--blue); }
.filter-btn.active { background: var(--blue); border-color: var(--blue); color: #fff; }
.filter-btn:focus  { outline: 2px solid var(--blue); outline-offset: 2px; }

#verifiedToggle { accent-color: var(--blue); width: 18px; height: 18px; cursor: pointer; }

/* Main grid */
.main { padding: 20px 24px; }

#resultCount { font-size: .82rem; color: var(--gray-5); margin-bottom: 14px; }

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 16px;
}

/* Advertiser card */
.card {
  background: #fff;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: box-shadow .15s;
}
.card:hover { box-shadow: var(--shadow-md); }

.card-thumb {
  width: 100%;
  aspect-ratio: 16/7;
  background: var(--gray-2);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.card-thumb img { width: 100%; height: 100%; object-fit: contain; }
.card-thumb .placeholder {
  color: var(--gray-5);
  font-size: .75rem;
  text-align: center;
  padding: 8px;
  user-select: none;
}

.card-body { padding: 14px 16px; flex: 1; display: flex; flex-direction: column; gap: 8px; }

.card-name { font-size: 1rem; font-weight: 700; color: var(--gray-9); }
.card-name a { color: inherit; text-decoration: none; }
.card-name a:hover { text-decoration: underline; color: var(--blue); }

.card-meta { font-size: .78rem; color: var(--gray-5); }

/* Status badge */
.status-badge {
  display: inline-block;
  border-radius: 999px;
  font-size: .72rem;
  font-weight: 700;
  padding: 2px 10px;
  letter-spacing: .02em;
  text-transform: uppercase;
}
.badge-verified    { background: #d1fae5; color: var(--green); }
.badge-past        { background: #e0f2fe; color: #0369a1; }
.badge-prospect    { background: #fef3c7; color: var(--amber); }
.badge-research    { background: var(--gray-2); color: var(--gray-5); }
.badge-speaker     { background: #f3e8ff; color: #6b21a8; }
.badge-disqualified{ background: #fee2e2; color: var(--red); }

/* Sightings list */
.sightings-list { list-style: none; font-size: .78rem; color: var(--gray-7); }
.sightings-list li { padding: 3px 0; border-bottom: 1px solid var(--gray-2); }
.sightings-list li:last-child { border: none; }
.sightings-list a { color: var(--blue); word-break: break-all; }
.sightings-list a:hover { text-decoration: underline; }

/* Contacts list */
.contacts-list { list-style: none; font-size: .78rem; }
.contacts-list li { padding: 2px 0; color: var(--gray-7); }

/* No-results */
.empty {
  grid-column: 1/-1;
  text-align: center;
  padding: 60px 0;
  color: var(--gray-5);
  font-size: .9rem;
}

/* Footer */
.footer {
  background: var(--gray-2);
  border-top: 1px solid var(--gray-3);
  padding: 14px 24px;
  font-size: .75rem;
  color: var(--gray-5);
  text-align: center;
  margin-top: 24px;
}
.footer a { color: var(--blue); }

/* Print styles */
@media print {
  .toolbar, .stats-bar { display: none; }
  .header { background: #222; }
  .card { break-inside: avoid; box-shadow: none; border: 1px solid var(--gray-3); }
  body { background: #fff; }
  .card-thumb img { max-height: 120px; }
}

/* Focus ring for keyboard nav */
a:focus, button:focus, input:focus, [tabindex]:focus {
  outline: 2px solid var(--blue);
  outline-offset: 2px;
}
</style>
</head>
<body>

<header class="header" role="banner">
  <h1>RENTV Advertiser Intelligence</h1>
  <span class="badge" aria-label="Offline viewer">Offline Viewer</span>
  <div class="meta" aria-label="Data export date">Data as of <strong>${h(exportDate)}</strong></div>
</header>

<section class="stats-bar" aria-label="Summary statistics">
  <div class="stat"><span class="num" id="statTotal">${totalAdvertisers}</span><span class="lbl">Companies</span></div>
  <div class="stat"><span class="num" id="statVerified">${verifiedCount}</span><span class="lbl">Verified advertisers/sponsors</span></div>
  <div class="stat"><span class="num">${caCount}</span><span class="lbl">California</span></div>
  <div class="stat"><span class="num">${azCount}</span><span class="lbl">Arizona</span></div>
  <div class="stat"><span class="lbl">Source coverage: ${h(sourceCoverage.total || 0)} companies, ${h(sourceCoverage.withEvidence || 0)} with evidence</span></div>
</section>

<section class="toolbar" role="search" aria-label="Search and filter advertisers">
  <label for="searchInput">Search</label>
  <input
    type="search"
    id="searchInput"
    placeholder="Company name, domain, city, state..."
    aria-label="Search advertisers"
    autocomplete="off"
    spellcheck="false"
  >

  <div class="filter-btns" role="group" aria-label="State filters">
    <button class="filter-btn active" data-state="ALL"  aria-pressed="true">All</button>
    <button class="filter-btn"        data-state="CA"   aria-pressed="false">California</button>
    <button class="filter-btn"        data-state="AZ"   aria-pressed="false">Arizona</button>
  </div>

  <div style="display:flex;align-items:center;gap:6px;">
    <input type="checkbox" id="verifiedToggle" aria-label="Show verified only">
    <label for="verifiedToggle" style="font-size:.82rem;font-weight:600;color:var(--gray-7);cursor:pointer;">
      Verified only
    </label>
  </div>
</section>

<main class="main" id="main" role="main">
  <div id="resultCount" aria-live="polite" aria-atomic="true"></div>
  <div class="grid" id="grid" role="list" aria-label="Advertiser cards"></div>
</main>

<footer class="footer" role="contentinfo">
  RENTV Advertiser Intelligence — exported ${h(exportDate)}.
  This file contains confidential business intelligence. Do not redistribute.
  Source: <a href="https://rentv.com" target="_blank" rel="noopener">rentv.com</a>.
</footer>

<script>
/* ================================================================
   RENTV Advertiser Intelligence — Offline Viewer Runtime
   Vanilla JS, no external dependencies, works on file:// protocol.
   ================================================================ */

// --------------- Inline data ---------------
const DATA = ${dataJson};

const STATUS_LABELS  = ${statusLabelsJson};
const VERIFIED_SET   = new Set(${verifiedStatusesJson});

// --------------- Utilities ---------------
function h(v) {
  if (v == null) return '';
  return String(v)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

function statusBadgeClass(status) {
  if (VERIFIED_SET.has(status)) return 'badge-verified';
  if (status === 'PAST_ADVERTISER') return 'badge-past';
  if (status === 'LIKELY_PROSPECT') return 'badge-prospect';
  if (status === 'SPEAKER_OR_PANELIST_ONLY') return 'badge-speaker';
  if (status === 'DISQUALIFIED') return 'badge-disqualified';
  return 'badge-research';
}

// Simple multi-word search: all space-separated tokens must appear somewhere
function matchesSearch(org, q) {
  if (!q) return true;
  const tokens = q.toLowerCase().split(/\\s+/).filter(Boolean);
  const haystack = [
    org.display_name || '',
    org.domain || '',
    org.headquarters_city || '',
    org.headquarters_state || '',
    org.organization_type || '',
    (org.advertiser_categories || []).join(' '),
    (org.sightings || []).map(s => s.headline || '').join(' '),
  ].join(' ').toLowerCase();
  return tokens.every(t => haystack.includes(t));
}

// --------------- State ---------------
let activeState = 'ALL';
let verifiedOnly = false;
let searchQ = '';

// --------------- Render ---------------
function renderCard(org) {
  const label = STATUS_LABELS[org.best_status] || org.best_status;
  const badgeClass = statusBadgeClass(org.best_status);

  // Thumbnail
  let thumbHtml;
  if (org.thumbnail_asset_id && DATA.thumbnails[org.thumbnail_asset_id]) {
    const dataUri = DATA.thumbnails[org.thumbnail_asset_id];
    thumbHtml = \`<div class="card-thumb"><img src="\${dataUri}" alt="Ad thumbnail for \${h(org.display_name)}" loading="lazy"></div>\`;
  } else {
    thumbHtml = \`<div class="card-thumb"><span class="placeholder" aria-hidden="true">No ad thumbnail available</span></div>\`;
  }

  // Domain link
  const nameHtml = org.domain
    ? \`<a href="https://\${h(org.domain)}" target="_blank" rel="noopener noreferrer" aria-label="\${h(org.display_name)} website">\${h(org.display_name)}</a>\`
    : h(org.display_name);

  // Location
  const loc = [org.headquarters_city, org.headquarters_state].filter(Boolean).join(', ');

  // Up to 3 sightings with source links
  const sightings = (org.sightings || []).slice(0, 3);
  const sightingsHtml = sightings.length
    ? \`<ul class="sightings-list" aria-label="Ad sightings">
        \${sightings.map(s => {
          const label = STATUS_LABELS[s.relationship_status] || s.relationship_status;
          const date = s.observed_at ? new Date(s.observed_at).toLocaleDateString() : '';
          const linkHtml = s.source_page_url
            ? \` — <a href="\${h(s.source_page_url)}" target="_blank" rel="noopener noreferrer">Source</a>\`
            : '';
          return \`<li>\${h(label)}\${s.headline ? ': ' + h(s.headline) : ''}\${date ? ' (' + date + ')' : ''}\${linkHtml}</li>\`;
        }).join('')}
      </ul>\`
    : '';

  // Up to 2 public contacts (email/phone only)
  const publicContacts = (org.contacts || [])
    .filter(c => c.explicitly_public && ['BUSINESS_EMAIL','BUSINESS_PHONE'].includes(c.type))
    .slice(0, 2);
  const contactsHtml = publicContacts.length
    ? \`<ul class="contacts-list" aria-label="Public contacts">
        \${publicContacts.map(c => {
          if (c.type === 'BUSINESS_EMAIL') {
            return \`<li><a href="mailto:\${h(c.value)}">\${h(c.value)}</a></li>\`;
          }
          return \`<li>\${h(c.value)}</li>\`;
        }).join('')}
      </ul>\`
    : '';

  return \`
<article class="card" role="listitem" aria-label="\${h(org.display_name)} — \${h(label)}">
  \${thumbHtml}
  <div class="card-body">
    <div class="card-name">\${nameHtml}</div>
    \${loc ? \`<div class="card-meta">\${h(loc)}</div>\` : ''}
    <div>
      <span class="status-badge \${badgeClass}" title="Classification: \${h(label)}">\${h(label)}</span>
    </div>
    \${sightingsHtml}
    \${contactsHtml}
  </div>
</article>\`;
}

function applyFilters() {
  const q = searchQ.trim();
  const filtered = DATA.advertisers.filter(org => {
    if (activeState !== 'ALL' && (org.headquarters_state || '').toUpperCase() !== activeState) return false;
    if (verifiedOnly && !VERIFIED_SET.has(org.best_status)) return false;
    if (q && !matchesSearch(org, q)) return false;
    return true;
  });

  const grid = document.getElementById('grid');
  const countEl = document.getElementById('resultCount');

  if (filtered.length === 0) {
    grid.innerHTML = '<div class="empty" role="status">No advertisers match the current filters.</div>';
    countEl.textContent = 'No results.';
    return;
  }

  countEl.textContent = \`Showing \${filtered.length} of \${DATA.advertisers.length} companies.\`;
  grid.innerHTML = filtered.map(renderCard).join('');
}

// --------------- Event wiring ---------------
document.getElementById('searchInput').addEventListener('input', (e) => {
  searchQ = e.target.value;
  applyFilters();
});

document.querySelectorAll('.filter-btn[data-state]').forEach(btn => {
  btn.addEventListener('click', () => {
    activeState = btn.dataset.state;
    document.querySelectorAll('.filter-btn[data-state]').forEach(b => {
      const on = b === btn;
      b.classList.toggle('active', on);
      b.setAttribute('aria-pressed', on ? 'true' : 'false');
    });
    applyFilters();
  });
});

document.getElementById('verifiedToggle').addEventListener('change', (e) => {
  verifiedOnly = e.target.checked;
  applyFilters();
});

// Initial render
applyFilters();
</script>
</body>
</html>`;
}

module.exports = { generateExecutiveViewer };