← 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
/**
* 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
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 };