tool/Automation/functions/Export-HtmlReport.ps1

function Export-HtmlReport {
    <#
    .SYNOPSIS
        Generate a self-contained interactive HTML report from scored results.

    .DESCRIPTION
        Produces a single HTML file with embedded CSS/JS (no external dependencies).
        Features: score bar chart, band distribution, sortable table, detail expansion,
        band/urgency filter, and search.

    .PARAMETER Results
        Array of flat result hashtables (same shape as opportunity-report.json .results).

    .PARAMETER Metadata
        Hashtable with profile, exported_at, source info.

    .PARAMETER OutputPath
        Directory to write opportunity-report.html into.

    .OUTPUTS
        String — path to the generated HTML file.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [array]$Results,

        [Parameter(Mandatory)]
        [hashtable]$Metadata,

        [Parameter(Mandatory)]
        [string]$OutputPath
    )

    $htmlPath = Join-Path $OutputPath 'opportunity-report.html'

    # Convert results to JSON for embedding
    $jsonData = $Results | ConvertTo-Json -Depth 5 -Compress

    # Band stats
    $bandCounts = @(0, 0, 0, 0, 0)
    foreach ($r in $Results) {
        $band = [int]$r.Band
        if ($band -ge 1 -and $band -le 5) { $bandCounts[$band - 1]++ }
    }

    $totalContacts = $Results.Count
    $avgScore = if ($totalContacts -gt 0) { [math]::Round(($Results | ForEach-Object { $_.'Final Score' } | Measure-Object -Average).Average, 1) } else { 0 }
    $maxScore = if ($totalContacts -gt 0) { ($Results | ForEach-Object { $_.'Final Score' } | Measure-Object -Maximum).Maximum } else { 0 }

    # Overview KPIs and data-integrity figures
    $actionable = $bandCounts[0] + $bandCounts[1] + $bandCounts[2]
    $withActions = @($Results | Where-Object { $_.'Recommended Action' -and $_.'Recommended Action'.ToString().Trim() }).Count
    $sortedScores = @($Results | ForEach-Object { [double]$_.'Final Score' } | Sort-Object)
    $medianScore = if ($sortedScores.Count -gt 0) {
        $mid = [int][math]::Floor($sortedScores.Count / 2)
        if ($sortedScores.Count % 2 -eq 0) { [math]::Round((($sortedScores[$mid - 1] + $sortedScores[$mid]) / 2), 1) }
        else { [math]::Round($sortedScores[$mid], 1) }
    }
    else { 0 }
    $maxScoreDisplay = [int][math]::Round([double]$maxScore)
    $avgScoreDisplay = [math]::Round([double]$avgScore, 1)
    $medianDisplay = [math]::Round([double]$medianScore, 1)
    $profileName = if ($Metadata.profile) { $Metadata.profile } else { 'Default' }
    $sourceName = if ($Metadata.source) { $Metadata.source } else { 'pipeline' }
    $generatedAt = Get-Date -Format 'yyyy-MM-dd HH:mm'

    $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LeadForge Opportunity Report</title>
<style>
:root {
    /* Forge theme */
    --graphite: #222B33; --graphite-2: #2C3E50;
    --amber: #E67E22; --amber-glow: #F39C12;
    --canvas: #F8F9FA; --card: #FFFFFF; --muted: #6C757D; --hairline: #DEE2E6;
    --text: #2C3E50;
    /* Band scale */
    --band1: #2ECC71; --band2: #3498DB; --band3: #F39C12; --band4: #E74C3C; --band5: #95A5A6;
    /* Category series */
    --cat-sales: #E67E22; --cat-partner: #5D6D7E; --cat-fund: #95A5A6;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; background: var(--canvas); color: var(--text); line-height: 1.5; }
a { color: var(--amber); text-decoration: none; }
a:hover { color: var(--amber-glow); }

/* Top bar */
.topbar { position: sticky; top: 0; z-index: 50; background: var(--graphite); box-shadow: 0 2px 8px rgba(0,0,0,0.18); }
.topbar-inner { max-width: 1400px; margin: 0 auto; padding: 0.9rem 1.5rem 0.6rem; display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.75rem 1.25rem; }
.wordmark { font-size: 1.35rem; font-weight: 800; letter-spacing: 1.5px; }
.wordmark .lead { color: #FFFFFF; }
.wordmark .forge { color: var(--amber); }
.context-strip { color: #AEB8C0; font-size: 0.82rem; display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; align-items: center; }
.context-strip .ctx-key { color: #8A95A0; }
.context-strip .ctx-val { color: #E6EAED; font-weight: 600; }
.context-strip .dot { color: #4B5862; }

/* Section nav */
.section-nav { max-width: 1400px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 1.5rem; }
.nav-link { color: #AEB8C0; font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; padding: 0.55rem 0.1rem; border-bottom: 3px solid transparent; transition: color 0.15s, border-color 0.15s; }
.nav-link:hover { color: var(--amber-glow); }
.nav-link.active { color: var(--amber); border-bottom-color: var(--amber); }

/* Layout */
.container { max-width: 1400px; margin: 0 auto; padding: 1.75rem 1.5rem 3rem; }
.section { margin-bottom: 2.5rem; scroll-margin-top: 96px; }
.section-title { font-size: 1.15rem; font-weight: 700; color: var(--graphite-2); margin-bottom: 1rem; padding-bottom: 0.4rem; border-bottom: 2px solid var(--amber); display: inline-block; }

/* KPI cards */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.kpi-card { background: var(--card); border-radius: 8px; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border-top: 3px solid var(--amber); }
.kpi-card .value { font-size: 2rem; font-weight: 800; color: var(--graphite-2); line-height: 1.1; }
.kpi-card .label { font-size: 0.72rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.6px; margin-top: 0.35rem; }
.kpi-card .sub { font-size: 0.75rem; color: var(--muted); margin-top: 0.2rem; }

/* Band distribution */
.band-dist-card { background: var(--card); border-radius: 8px; padding: 1.25rem 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 1rem; }
.band-dist-card h3 { font-size: 0.95rem; color: var(--graphite-2); margin-bottom: 0.85rem; }
.dist-bar { display: flex; height: 26px; border-radius: 6px; overflow: hidden; background: var(--canvas); }
.dist-seg { height: 100%; transition: width 0.4s; }
.dist-legend { display: flex; flex-wrap: wrap; gap: 0.6rem 1.25rem; margin-top: 0.85rem; }
.dist-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: var(--text); }
.dist-swatch { width: 11px; height: 11px; border-radius: 3px; flex-shrink: 0; }
.dist-item .dist-count { color: var(--muted); }

.integrity-note { font-size: 0.8rem; color: var(--muted); background: var(--card); border-left: 3px solid var(--hairline); border-radius: 0 6px 6px 0; padding: 0.75rem 1rem; }
.integrity-note strong { color: var(--graphite-2); }

/* Charts */
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.chart-card { background: var(--card); border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.chart-card.span-2 { grid-column: 1 / -1; }
.chart-card h3 { font-size: 0.95rem; color: var(--graphite-2); margin-bottom: 1rem; }

/* Histogram */
.histogram { display: flex; align-items: flex-end; gap: 3px; height: 150px; }
.hist-bar-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; }
.hist-bar { width: 100%; border-radius: 3px 3px 0 0; min-height: 0; transition: height 0.3s; }
.hist-bar:hover { opacity: 0.82; }
.hist-count { font-size: 0.7rem; font-weight: 700; margin-bottom: 2px; color: var(--graphite-2); }
.hist-label { font-size: 0.68rem; color: var(--muted); margin-top: 5px; text-align: center; }

/* Horizontal bars */
.hbar-chart { display: flex; flex-direction: column; gap: 7px; }
.hbar-row { display: flex; align-items: center; gap: 8px; }
.hbar-name { width: 140px; font-size: 0.8rem; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; color: var(--text); }
.hbar-track { flex: 1; height: 22px; background: var(--canvas); border-radius: 4px; overflow: hidden; }
.hbar-fill { height: 100%; border-radius: 4px; transition: width 0.4s; display: flex; align-items: center; padding-left: 7px; min-width: 2px; }
.hbar-score { font-size: 0.74rem; font-weight: 700; color: #fff; text-shadow: 0 1px 1px rgba(0,0,0,0.3); }
.hbar-org { font-size: 0.7rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; width: 120px; flex-shrink: 0; }
.hbar-more { font-size: 0.78rem; color: var(--muted); margin-top: 6px; text-align: center; }

/* Legend (chart bottom) */
.legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; margin-top: 1rem; padding-top: 0.85rem; border-top: 1px solid var(--hairline); }
.legend-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.78rem; color: var(--text); }
.legend-swatch { width: 11px; height: 11px; border-radius: 3px; flex-shrink: 0; }

/* Controls */
.controls { display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; align-items: center; }
.search-input { padding: 0.55rem 1rem; border: 1px solid var(--hairline); border-radius: 6px; font-size: 0.9rem; width: 280px; font-family: inherit; }
.search-input:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px rgba(230,126,34,0.15); }
.band-filters { display: flex; gap: 0.45rem; flex-wrap: wrap; }
.band-pill { padding: 0.4rem 0.85rem; border-radius: 20px; font-size: 0.8rem; font-weight: 600; color: #fff; cursor: pointer; user-select: none; transition: opacity 0.15s, transform 0.15s; }
.band-pill:hover { transform: translateY(-1px); }
.band-pill.off { opacity: 0.35; }
.band-pill.b1 { background: var(--band1); } .band-pill.b2 { background: var(--band2); }
.band-pill.b3 { background: var(--band3); } .band-pill.b4 { background: var(--band4); }
.band-pill.b5 { background: var(--band5); }

/* Table */
.table-wrap { background: var(--card); border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
thead { background: var(--graphite); color: #fff; position: sticky; top: 72px; z-index: 10; }
thead tr { border-bottom: 3px solid var(--amber); }
th { padding: 0.75rem 0.6rem; text-align: left; cursor: pointer; user-select: none; white-space: nowrap; font-weight: 600; }
th:hover { background: var(--graphite-2); }
th .sort-arrow { margin-left: 4px; opacity: 0.4; }
th.sorted .sort-arrow { opacity: 1; color: var(--amber-glow); }
td { padding: 0.6rem; border-bottom: 1px solid var(--hairline); vertical-align: top; }
tbody tr.data-row:nth-child(4n+1) { background: #FCFCFD; }
tbody tr.data-row:hover { background: #FBF1E7; cursor: pointer; }
tr.band-1 td:first-child { border-left: 4px solid var(--band1); }
tr.band-2 td:first-child { border-left: 4px solid var(--band2); }
tr.band-3 td:first-child { border-left: 4px solid var(--band3); }
tr.band-4 td:first-child { border-left: 4px solid var(--band4); }
tr.band-5 td:first-child { border-left: 4px solid var(--band5); }
.score-cell { font-weight: 800; color: var(--graphite-2); }
.band-dot { font-size: 0.7rem; vertical-align: middle; }

/* Detail row */
.detail-row td { padding: 0; border: none; background: #F4F6F8; }
.detail-content { padding: 1.1rem 1.5rem; display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; font-size: 0.85rem; border-left: 4px solid var(--amber); }
.detail-content h4 { grid-column: 1 / -1; color: var(--graphite-2); font-size: 0.95rem; }
.detail-content .field { display: flex; flex-direction: column; }
.detail-content .field-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-content .field-value { font-weight: 500; }
.score-breakdown { display: flex; gap: 0.4rem; flex-wrap: wrap; grid-column: 1 / -1; }
.score-chip { padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.75rem; background: #E9EDF1; color: var(--graphite-2); }
.score-chip.composite { background: #D4EFDF; font-weight: 700; }
.score-chip.penalty { background: #FADBD8; font-weight: 700; }

.empty-state { padding: 2rem; text-align: center; color: var(--muted); }

@media (max-width: 768px) {
    .charts-grid { grid-template-columns: 1fr; }
    .kpi-grid { grid-template-columns: repeat(2, 1fr); }
    .controls { flex-direction: column; align-items: stretch; }
    .search-input { width: 100%; }
    .detail-content { grid-template-columns: 1fr; }
    .hbar-name { width: 96px; }
    .hbar-org { display: none; }
    thead { top: 0; }
}
</style>
</head>
<body>
<header class="topbar">
    <div class="topbar-inner">
        <div class="wordmark"><span class="lead">LEAD</span><span class="forge">FORGE</span></div>
        <div class="context-strip">
            <span class="ctx-key">Opportunity Report</span>
            <span class="dot">&bull;</span>
            <span class="ctx-key">Profile</span> <span class="ctx-val">$profileName</span>
            <span class="dot">&bull;</span>
            <span class="ctx-key">Source</span> <span class="ctx-val">$sourceName</span>
            <span class="dot">&bull;</span>
            <span class="ctx-key">Generated</span> <span class="ctx-val">$generatedAt</span>
        </div>
    </div>
    <nav class="section-nav">
        <a href="#overview" class="nav-link active" data-target="overview">Overview</a>
        <a href="#explore" class="nav-link" data-target="explore">Explore</a>
        <a href="#detail" class="nav-link" data-target="detail">Detail</a>
    </nav>
</header>

<main class="container">
    <!-- ============ OVERVIEW ============ -->
    <section id="overview" class="section">
        <h2 class="section-title">Overview</h2>
        <div class="kpi-grid">
            <div class="kpi-card"><div class="value">$totalContacts</div><div class="label">Contacts Scored</div><div class="sub">$withActions with AI actions</div></div>
            <div class="kpi-card"><div class="value">$maxScoreDisplay</div><div class="label">Top Score</div><div class="sub">Median $medianDisplay</div></div>
            <div class="kpi-card"><div class="value">$avgScoreDisplay</div><div class="label">Average Score</div><div class="sub">After recency penalties</div></div>
            <div class="kpi-card"><div class="value">$actionable</div><div class="label">Actionable (Bands 1-3)</div><div class="sub" id="actionablePct"></div></div>
        </div>

        <div class="band-dist-card">
            <h3>Band Distribution</h3>
            <div class="dist-bar" id="distBar"></div>
            <div class="dist-legend" id="distLegend"></div>
        </div>

        <p class="integrity-note">
            <strong>Data integrity:</strong> All scores are deterministic and reproducible from the same inputs.
            Final score = composite score minus the profile recency penalty (clamped at zero); a score of
            <strong>0</strong> (shown as &ldquo;&ndash;&rdquo;) means the contact is archived. AI-generated actions are present for
            <strong>$withActions</strong> of <strong>$totalContacts</strong> contacts; missing actions do not affect the deterministic score.
        </p>
    </section>

    <!-- ============ EXPLORE ============ -->
    <section id="explore" class="section">
        <h2 class="section-title">Explore</h2>
        <div class="charts-grid">
            <div class="chart-card">
                <h3>Score Distribution</h3>
                <div class="histogram" id="histogram"></div>
                <div class="legend" id="histLegend"></div>
            </div>
            <div class="chart-card">
                <h3>Top Opportunities</h3>
                <div class="hbar-chart" id="topChart"></div>
            </div>
            <div class="chart-card span-2">
                <h3>Category Breakdown</h3>
                <div class="hbar-chart" id="catChart"></div>
                <div class="legend" id="catLegend"></div>
            </div>
        </div>
    </section>

    <!-- ============ DETAIL ============ -->
    <section id="detail" class="section">
        <h2 class="section-title">Detail</h2>
        <div class="controls">
            <input type="text" class="search-input" id="searchInput" placeholder="Search name, company, email..." oninput="filterTable()">
            <div class="band-filters">
                <span class="band-pill b1" data-band="1" onclick="toggleBand(1)">B1 Immediate ($($bandCounts[0]))</span>
                <span class="band-pill b2" data-band="2" onclick="toggleBand(2)">B2 High ($($bandCounts[1]))</span>
                <span class="band-pill b3" data-band="3" onclick="toggleBand(3)">B3 Medium ($($bandCounts[2]))</span>
                <span class="band-pill b4" data-band="4" onclick="toggleBand(4)">B4 Low ($($bandCounts[3]))</span>
                <span class="band-pill b5" data-band="5" onclick="toggleBand(5)">B5 Archive ($($bandCounts[4]))</span>
            </div>
        </div>
        <div class="table-wrap">
            <table id="reportTable">
                <thead>
                    <tr>
                        <th onclick="sortTable(0)">Score <span class="sort-arrow">&#9650;</span></th>
                        <th onclick="sortTable(1)">Band <span class="sort-arrow">&#9650;</span></th>
                        <th onclick="sortTable(2)">Name <span class="sort-arrow">&#9650;</span></th>
                        <th onclick="sortTable(3)">Organisation <span class="sort-arrow">&#9650;</span></th>
                        <th onclick="sortTable(4)">Category <span class="sort-arrow">&#9650;</span></th>
                        <th onclick="sortTable(5)">Confidence <span class="sort-arrow">&#9650;</span></th>
                        <th onclick="sortTable(6)">Action <span class="sort-arrow">&#9650;</span></th>
                    </tr>
                </thead>
                <tbody id="tableBody"></tbody>
            </table>
        </div>
    </section>
</main>

<script>
const DATA = $jsonData;

const bandColors = {1:'#2ECC71',2:'#3498DB',3:'#F39C12',4:'#E74C3C',5:'#95A5A6'};
const bandNames = {1:'Immediate',2:'High',3:'Medium',4:'Low',5:'Archive'};
const catColors = {'Sales/BD':'#E67E22','Partnership':'#5D6D7E','Fundraising':'#95A5A6'};
const catFallback = '#6C757D';

let activeBands = new Set([1,2,3,4,5]);
let sortCol = -1, sortAsc = false;

// --- Formatting helpers ---
function fmtScore(v) { const n = Math.round(Number(v)||0); return n <= 0 ? '\u2013' : String(n); }
function fmtPct(part, whole) { if (!whole) return '0.0%'; return ((part/whole)*100).toFixed(1) + '%'; }
function esc(s) { if (s===null||s===undefined) return ''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
function primaryCategory(cat) { if (!cat) return 'Uncategorised'; return cat.toString().split(',')[0].trim() || 'Uncategorised'; }

// --- Overview ---
function renderOverview() {
    const total = DATA.length;
    const counts = {1:0,2:0,3:0,4:0,5:0};
    DATA.forEach(r => { if (counts[r.Band] !== undefined) counts[r.Band]++; });

    const actionable = counts[1]+counts[2]+counts[3];
    document.getElementById('actionablePct').textContent = fmtPct(actionable, total) + ' of pipeline';

    const bar = document.getElementById('distBar');
    bar.innerHTML = [1,2,3,4,5].map(b => {
        const w = total ? (counts[b]/total)*100 : 0;
        if (w === 0) return '';
        return '<div class="dist-seg" style="width:'+w+'%;background:'+bandColors[b]+'" title="Band '+b+' '+bandNames[b]+': '+counts[b]+'"></div>';
    }).join('');
    document.getElementById('distLegend').innerHTML = [1,2,3,4,5].map(b =>
        '<div class="dist-item"><span class="dist-swatch" style="background:'+bandColors[b]+'"></span>'
        +'Band '+b+' '+bandNames[b]+' <span class="dist-count">'+counts[b]+' &middot; '+fmtPct(counts[b], total)+'</span></div>'
    ).join('');
}

// --- Explore charts ---
function renderCharts() {
    // Score histogram (10 buckets of 10), bar coloured by dominant band, legend below
    const hist = document.getElementById('histogram');
    const bucketSize = 10;
    const buckets = Array.from({length:10}, ()=>({count:0, bands:{}}));
    DATA.forEach(r => {
        const idx = Math.min(9, Math.max(0, Math.floor((Number(r['Final Score'])||0) / bucketSize)));
        buckets[idx].count++;
        buckets[idx].bands[r.Band] = (buckets[idx].bands[r.Band]||0) + 1;
    });
    const maxCount = Math.max(...buckets.map(b=>b.count), 1);
    hist.innerHTML = buckets.map((b,i) => {
        const h = b.count > 0 ? Math.max(8, (b.count/maxCount)*100) : 0;
        let dom = 5, mx = 0;
        for (const [band, cnt] of Object.entries(b.bands)) { if (cnt > mx) { mx = cnt; dom = parseInt(band); } }
        const color = bandColors[dom]||'#ccc';
        const label = (i*bucketSize)+'-'+(i*bucketSize+bucketSize-1);
        return '<div class="hist-bar-wrap">'
            +(b.count>0?'<div class="hist-count">'+b.count+'</div>':'')
            +'<div class="hist-bar" style="height:'+h+'%;background:'+color+'" title="'+label+': '+b.count+' contacts"></div>'
            +'<div class="hist-label">'+label+'</div></div>';
    }).join('');
    document.getElementById('histLegend').innerHTML = [1,2,3,4,5].map(b =>
        '<div class="legend-item"><span class="legend-swatch" style="background:'+bandColors[b]+'"></span>Band '+b+' '+bandNames[b]+'</div>'
    ).join('');

    // Top 10 horizontal bars, coloured by band
    const topChart = document.getElementById('topChart');
    const top10 = [...DATA].sort((a,b)=>(Number(b['Final Score'])||0)-(Number(a['Final Score'])||0)).slice(0, 10);
    const topMax = Math.max(...top10.map(r=>Number(r['Final Score'])||0), 1);
    const remaining = DATA.length - top10.length;
    topChart.innerHTML = top10.map(r => {
        const score = Number(r['Final Score'])||0;
        const pct = Math.max(2, (score/topMax)*100);
        const color = bandColors[r.Band]||'#ccc';
        return '<div class="hbar-row">'
            +'<div class="hbar-name" title="'+esc(r['Contact Name'])+'">'+esc(r['Contact Name'])+'</div>'
            +'<div class="hbar-track"><div class="hbar-fill" style="width:'+pct+'%;background:'+color+'"><span class="hbar-score">'+fmtScore(score)+'</span></div></div>'
            +'<div class="hbar-org" title="'+esc(r.Organisation)+'">'+esc(r.Organisation)+'</div>'
            +'</div>';
    }).join('') + (remaining > 0 ? '<div class="hbar-more">+ '+remaining+' more in the table below</div>' : '');

    // Category breakdown, coloured by category series
    const catCounts = {};
    DATA.forEach(r => { const c = primaryCategory(r.Category); catCounts[c] = (catCounts[c]||0) + 1; });
    const catEntries = Object.entries(catCounts).sort((a,b)=>b[1]-a[1]);
    const catMax = Math.max(...catEntries.map(e=>e[1]), 1);
    document.getElementById('catChart').innerHTML = catEntries.map(([cat,cnt]) => {
        const pct = Math.max(2, (cnt/catMax)*100);
        const color = catColors[cat]||catFallback;
        return '<div class="hbar-row">'
            +'<div class="hbar-name" title="'+esc(cat)+'">'+esc(cat)+'</div>'
            +'<div class="hbar-track"><div class="hbar-fill" style="width:'+pct+'%;background:'+color+'"><span class="hbar-score">'+cnt+'</span></div></div>'
            +'<div class="hbar-org">'+fmtPct(cnt, DATA.length)+'</div>'
            +'</div>';
    }).join('');
    const legendCats = Object.keys(catColors).filter(c => catCounts[c]);
    const hasOther = catEntries.some(([c]) => !catColors[c]);
    document.getElementById('catLegend').innerHTML =
        legendCats.map(c => '<div class="legend-item"><span class="legend-swatch" style="background:'+catColors[c]+'"></span>'+esc(c)+'</div>').join('')
        + (hasOther ? '<div class="legend-item"><span class="legend-swatch" style="background:'+catFallback+'"></span>Other</div>' : '');
}

// --- Detail table ---
function toggleBand(b) {
    const pill = document.querySelector('.band-pill[data-band="'+b+'"]');
    if (activeBands.has(b)) { activeBands.delete(b); pill.classList.add('off'); }
    else { activeBands.add(b); pill.classList.remove('off'); }
    renderTable();
}

function getFiltered() {
    const q = (document.getElementById('searchInput').value||'').toLowerCase();
    return DATA.filter(r => {
        if (!activeBands.has(r.Band)) return false;
        if (!q) return true;
        return [r['Contact Name'],r.Organisation,r['Contact Email'],r.Category,r['Recommended Action']]
            .some(v => v && v.toString().toLowerCase().includes(q));
    });
}

const SORT_KEYS = ['Final Score','Band','Contact Name','Organisation','Category','Research Confidence','Recommended Action'];
function sortRows(rows) {
    if (sortCol < 0) return rows;
    return rows.sort((a,b) => {
        let av = a[SORT_KEYS[sortCol]], bv = b[SORT_KEYS[sortCol]];
        if (typeof av === 'number' || typeof bv === 'number') return sortAsc ? (av-bv) : (bv-av);
        av = (av||'').toString().toLowerCase(); bv = (bv||'').toString().toLowerCase();
        return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
    });
}

function renderTable() {
    const tbody = document.getElementById('tableBody');
    const rows = sortRows(getFiltered());
    if (rows.length === 0) {
        tbody.innerHTML = '<tr><td colspan="7" class="empty-state">No contacts match the current filters.</td></tr>';
        return;
    }
    tbody.innerHTML = rows.map((r,i) => {
        const fullAction = (r['Recommended Action']||'');
        const action = fullAction.substring(0,80) + (fullAction.length>80?'\u2026':'');
        const conf = Math.round(Number(r['Research Confidence'])||0);
        return '<tr class="data-row band-'+r.Band+'" onclick="toggleDetail(this,'+i+')" data-idx="'+i+'">'
            +'<td><span class="score-cell">'+fmtScore(r['Final Score'])+'</span></td>'
            +'<td><span class="band-dot" style="color:'+bandColors[r.Band]+'">&#9679;</span> '+bandNames[r.Band]+'</td>'
            +'<td>'+esc(r['Contact Name'])+'</td>'
            +'<td>'+esc(r.Organisation)+'</td>'
            +'<td>'+esc(r.Category)+'</td>'
            +'<td>'+conf+'/5</td>'
            +'<td>'+esc(action)+'</td></tr>';
    }).join('');
}

function toggleDetail(tr, idx) {
    const rows = sortRows(getFiltered());
    const r = rows[idx];
    const existing = tr.nextElementSibling;
    if (existing && existing.classList.contains('detail-row')) { existing.remove(); return; }
    document.querySelectorAll('.detail-row').forEach(el=>el.remove());
    const detail = document.createElement('tr');
    detail.className = 'detail-row';
    detail.innerHTML = '<td colspan="7"><div class="detail-content">'
        +'<h4>'+esc(r['Contact Name'])+' &mdash; '+esc(r.Organisation)+'</h4>'
        +'<div class="field"><span class="field-label">Email</span><span class="field-value">'+esc(r['Contact Email'])+'</span></div>'
        +'<div class="field"><span class="field-label">Source File</span><span class="field-value">'+esc(r['Source File'])+'</span></div>'
        +'<div class="score-breakdown">'
        +'<span class="score-chip">Fit '+esc(r['Strategic Fit'])+'/5</span>'
        +'<span class="score-chip">Seniority '+esc(r.Seniority)+'/5</span>'
        +'<span class="score-chip">Warmth '+esc(r['Engagement Warmth'])+'/5</span>'
        +'<span class="score-chip">Market '+esc(r['Market Activity'])+'/5</span>'
        +'<span class="score-chip">Stage '+esc(r['Conversation Stage'])+'/5</span>'
        +'<span class="score-chip">Recency '+esc(r.Recency)+'/5</span>'
        +'<span class="score-chip">Confidence '+esc(r['Research Confidence'])+'/5</span>'
        +'<span class="score-chip composite">Composite '+esc(r['Composite Score'])+'</span>'
        +'<span class="score-chip penalty">Penalty -'+esc(r['Recency Penalty'])+'</span>'
        +'</div>'
        +'<div class="field" style="grid-column:1/-1"><span class="field-label">Recommended Action</span><span class="field-value">'+esc(r['Recommended Action'])+'</span></div>'
        +'<div class="field" style="grid-column:1/-1"><span class="field-label">Rationale</span><span class="field-value">'+esc(r['Action Rationale'])+'</span></div>'
        +'</div></td>';
    tr.after(detail);
}

function sortTable(col) {
    if (sortCol === col) sortAsc = !sortAsc;
    else { sortCol = col; sortAsc = col >= 2; }
    document.querySelectorAll('th').forEach(th=>th.classList.remove('sorted'));
    document.querySelectorAll('th')[col].classList.add('sorted');
    renderTable();
}

function filterTable() { renderTable(); }

// --- Section nav scroll-spy ---
function initNav() {
    var links = Array.from(document.querySelectorAll('.nav-link'));
    var sections = links.map(function(l) { return document.getElementById(l.dataset.target); });
    var scrollMargin = sections[0] ? parseInt(getComputedStyle(sections[0]).scrollMarginTop) || 96 : 96;
    var clickLock = null; // target id while smooth-scroll is in flight

    function onScroll() {
        // While a click-initiated smooth scroll is in flight, suppress the spy
        // so intermediate scroll positions don't overwrite the clicked tab
        if (clickLock) return;
        var pos = window.scrollY + scrollMargin + 2;
        var atBottom = (window.innerHeight + window.scrollY) >= (document.documentElement.scrollHeight - 2);
        if (atBottom) {
            links.forEach(function(l,i) { l.classList.toggle('active', i === links.length - 1); });
            return;
        }
        var active = sections[0].id;
        for (var i = 0; i < sections.length; i++) { if (sections[i] && sections[i].offsetTop <= pos) active = sections[i].id; }
        links.forEach(function(l) { l.classList.toggle('active', l.dataset.target === active); });
    }

    // Click handler: set active immediately, lock the spy until scroll settles
    links.forEach(function(l) {
        l.addEventListener('click', function(e) {
            e.preventDefault();
            var target = l.dataset.target;
            links.forEach(function(x) { x.classList.remove('active'); });
            l.classList.add('active');
            clickLock = target;
            var el = document.getElementById(target);
            if (el) el.scrollIntoView({ behavior: 'smooth' });
            // Release the lock after the smooth scroll settles
            // Use a scroll-idle detector: wait until no scroll events for 100ms
            var timer = null;
            function onSettled() {
                clearTimeout(timer);
                timer = setTimeout(function() {
                    window.removeEventListener('scroll', onSettled);
                    clickLock = null;
                }, 100);
            }
            window.addEventListener('scroll', onSettled, { passive: true });
            // Safety: release lock after 800ms even if no scroll events fire (already at target)
            setTimeout(function() { clickLock = null; }, 800);
        });
    });

    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();
}

// Init
renderOverview();
renderCharts();
sortTable(0);
initNav();
</script>
</body>
</html>
"@


    $html | Set-Content -Path $htmlPath -Encoding UTF8
    return $htmlPath
}