/* SparkSoftware Reporting — vanilla JS single-page app. No build step, no dependencies. */
(() => {
  'use strict';

  // ── state ────────────────────────────────────────────────────────────
  const state = {
    config: null,
    range: '28d',
    sort: { key: 'sessions', dir: -1 },
    chatOpen: false,
    chatMsgs: [],
    chatBusy: false,
    pageData: null, // cache of current view's data
    token: sessionStorage.getItem('lp_token') || '',
    portal: null, // { siteId, token, site } when viewing a client share link
    gaMarket: {}, // siteId → comma-separated market ids/names ('' = all markets)
    gaMarketMode: {}, // siteId → 'locale' (platform locale ids) | 'country' (GA names)
    gaMarketLabels: {}, // siteId → human label for the crumb/button
  };

  // Detect portal mode from the URL: /share/<siteId>/<token>
  const portalMatch = /^\/share\/([\w-]+)\/([a-f0-9]+)/.exec(location.pathname);
  if (portalMatch) state.portal = { siteId: portalMatch[1], token: portalMatch[2], site: null };
  // A monitoring-scoped share link shows ONLY the coverage report.
  if (state.portal && new URLSearchParams(location.search).get('view') === 'monitoring') state.portalView = 'monitoring';

  // ── theme (Spark light by default; dark and OS-auto as options) ──────
  function applyTheme(mode) {
    localStorage.setItem('lp-theme', mode);
    if (mode === 'auto') delete document.documentElement.dataset.theme;
    else document.documentElement.dataset.theme = mode;
  }
  function themeIcon() {
    const t = localStorage.getItem('lp-theme') || 'light';
    return t === 'light' ? '☀️' : t === 'dark' ? '🌙' : '🌗';
  }
  applyTheme(localStorage.getItem('lp-theme') || 'light');

  const RANGES = [
    ['7d', '7 days'], ['28d', '28 days'], ['90d', '90 days'], ['mtd', 'Month to date'], ['12m', '12 months'],
  ];

  // SparkSoftware dotted mark (inline SVG, inherits button colours)
  const LOGO = '<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><circle cx="5" cy="5" r="2.4"/><circle cx="12" cy="5" r="2.4"/><circle cx="19" cy="5" r="2.4"/><circle cx="5" cy="12" r="2.4"/><circle cx="12" cy="12" r="2.4"/><circle cx="19" cy="12" r="2.4"/><circle cx="5" cy="19" r="2.4"/><circle cx="12" cy="19" r="2.4"/></svg>';

  // ── utils ────────────────────────────────────────────────────────────
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  const fmtInt = (n) => (n == null ? '—' : Math.round(n).toLocaleString('en-GB'));
  const fmtPct = (n) => (n == null ? '—' : (n * 100).toFixed(1) + '%');
  const fmtDur = (s) => {
    if (s == null) return '—';
    const m = Math.floor(s / 60), sec = Math.round(s % 60);
    return m + 'm ' + String(sec).padStart(2, '0') + 's';
  };
  const delta = (cur, prev) => (!prev ? null : (cur - prev) / prev);
  const kfmt = (v) => v >= 1e6 ? (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
    : v >= 1000 ? (v / 1000).toFixed(v >= 10000 ? 0 : 1) + 'k' : String(Math.round(v));

  async function api(path, opts = {}) {
    // Analytics market filter: every site-scoped GA/platform/email call for
    // the CURRENT site carries the selection as &gm= (distinct from the
    // monitoring section's own &market=, which uses tenant market ids).
    const gmSel = (() => {
      try {
        const m = /^\/api\/site\/([\w-]+)(?:\/|\?|$)/.exec(path);
        if (!m) return null;
        const sel = state.gaMarket?.[m[1]];
        if (!sel || path.includes('gm=') || path.includes('gml=') || /what=countries/.test(path) || /\/markets(\?|$)/.test(path)) return null;
        return { sel, mode: state.gaMarketMode?.[m[1]] === 'locale' ? 'gml' : 'gm' };
      } catch { return null; }
    })();
    if (gmSel) path += (path.includes('?') ? '&' : '?') + gmSel.mode + '=' + encodeURIComponent(gmSel.sel);
    const res = await fetch(path, {
      ...opts,
      headers: {
        'Content-Type': 'application/json',
        ...(state.token ? { 'x-dashboard-token': state.token } : {}),
        ...(state.portal ? { 'x-share-token': state.portal.token, 'x-share-site': state.portal.siteId,
          ...(state.portal.pw ? { 'x-share-pw': state.portal.pw } : {}) } : {}),
      },
    });
    if (res.status === 401) { if (!state.portal) renderAuth('login'); throw new Error('unauthorised'); }
    if (res.status === 402) {
      // Licence lock — the trial ended or the key lapsed.
      let d = {};
      try { d = await res.json(); } catch { /* body optional */ }
      renderLicenseLock(d.license || {});
      throw new Error('licence required');
    }
    if (!res.ok) {
      let msg = 'HTTP ' + res.status;
      try { msg = (await res.json()).error || msg; } catch {}
      throw new Error(msg);
    }
    return res.json();
  }

  // ── licence lock screen — shown when the trial ends / the key lapses ───
  function renderLicenseLock(lic = {}) {
    if (state._licLocked) return; // don't re-paint on every failing call
    state._licLocked = true;
    const expired = lic.reason === 'licence-expired';
    root.innerHTML = `
      <div class="auth-wrap"><div class="auth-card" style="max-width:520px">
        <div style="font-size:26px;font-weight:800;margin-bottom:4px">Spark<span style="color:var(--series-1)">Software</span> Reporting</div>
        <h2 style="margin:10px 0 4px">${expired ? 'Licence expired' : 'Your free trial has ended'}</h2>
        <div class="note" style="line-height:1.55;margin-bottom:14px">
          ${expired
            ? `The licence${lic.licensee ? ' for <b>' + esc(lic.licensee) + '</b>' : ''} ran out${lic.expires ? ' on ' + new Date(lic.expires).toISOString().slice(0, 10) : ''}. Renew the subscription at spark-software.de — an active subscription renews the key automatically, or paste the current key below.`
            : `Thanks for trying SparkSoftware Reporting — the 30-day trial is over. Get a licence at spark-software.de (monthly, or yearly with 15% off) and paste the key below to unlock everything exactly as you left it. All your sites, settings and reports are untouched.`}
        </div>
        <a class="btn-primary" href="https://spark-software.de/reporting#pricing" target="_blank" rel="noopener" style="display:block;text-align:center;text-decoration:none;margin-bottom:12px">Get a licence — spark-software.de</a>
        <form id="lic-form" class="auth-form">
          <label class="note">Licence key</label>
          <textarea class="auth-in" name="key" rows="4" placeholder="SPARK.xxxxx.xxxxx" style="font-family:monospace;font-size:12px;resize:vertical" required></textarea>
          <button class="btn-primary" type="submit">Activate</button>
          <div class="note" id="lic-status" style="min-height:18px"></div>
        </form>
      </div></div>`;
    document.getElementById('lic-form').addEventListener('submit', async (e) => {
      e.preventDefault();
      const st = document.getElementById('lic-status');
      st.textContent = 'Checking key…';
      try {
        const r = await fetch('/api/license/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: new FormData(e.target).get('key') }) });
        const d = await r.json();
        if (!r.ok) throw new Error(d.error || 'Activation failed');
        st.innerHTML = `<span style="color:var(--good-text)">✓ Licensed to ${esc(d.license?.licensee || 'you')} — loading…</span>`;
        state._licLocked = false;
        setTimeout(() => location.reload(), 900);
      } catch (err) { st.innerHTML = `<span style="color:var(--bad-text)">${esc(err.message)}</span>`; }
    });
  }

  // ── tiny markdown renderer (headings, bold, code, lists, tables, links)
  function md(src) {
    const lines = String(src || '').split('\n');
    let html = '', inUl = false, inOl = false, table = null;
    const inline = (t) =>
      esc(t)
        .replace(/`([^`]+)`/g, '<code>$1</code>')
        .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
        .replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<i>$2</i>')
        // links: absolute AND app-relative (e.g. /reports/…pdf from the AI).
        // The model sometimes prefixes report paths with a fake scheme
        // (sandbox:/reports/…, file:/reports/…) — strip it so the download
        // button always renders.
        .replace(/\[([^\]]+)\]\((https?:[^)\s]+|(?:sandbox:|attachment:|file:)?\/?\/?reports\/[^)\s]+|\/[^)\s]+)\)/g, (m2, txt, url) => {
          url = url.replace(/^(?:sandbox:|attachment:|file:)\/*(?=reports\/)/i, '/').replace(/^(?=reports\/)/, '/');
          return `<a href="${url}" target="_blank" rel="noopener" ${url.startsWith('/reports/') ? 'class="dl-link"' : ''}>${url.startsWith('/reports/') ? '⬇ ' : ''}${txt}</a>`;
        })
        // bare /reports/… paths (with or without a fake scheme) become
        // download links too
        .replace(/(^|[\s>])(?:sandbox:|attachment:|file:)?\/*(\/reports\/[A-Za-z0-9._-]+|reports\/[A-Za-z0-9._-]+\.(?:pptx|pdf|html|docx|csv))/gi, (m2, pre, p) =>
          `${pre}<a href="${p.startsWith('/') ? p : '/' + p}" target="_blank" rel="noopener" class="dl-link">⬇ Download report</a>`);
    const closeLists = () => {
      if (inUl) { html += '</ul>'; inUl = false; }
      if (inOl) { html += '</ol>'; inOl = false; }
    };
    const flushTable = () => {
      if (!table) return;
      html += '<table><thead><tr>' + table.head.map((c) => '<th>' + inline(c) + '</th>').join('') + '</tr></thead><tbody>'
        + table.rows.map((r) => '<tr>' + r.map((c) => '<td>' + inline(c) + '</td>').join('') + '</tr>').join('')
        + '</tbody></table>';
      table = null;
    };
    for (const raw of lines) {
      const line = raw.replace(/\r$/, '');
      const t = line.trim();
      if (/^\|.*\|$/.test(t)) {
        const cells = t.slice(1, -1).split('|').map((c) => c.trim());
        if (cells.every((c) => /^:?-{2,}:?$/.test(c))) continue; // separator row
        if (!table) table = { head: cells, rows: [] };
        else table.rows.push(cells);
        continue;
      }
      flushTable();
      const h = /^(#{1,4})\s+(.*)$/.exec(t);
      if (h) { closeLists(); html += `<h${h[1].length + 1}>` + inline(h[2]) + `</h${h[1].length + 1}>`; continue; }
      if (/^[-*]\s+/.test(t)) {
        if (!inUl) { closeLists(); html += '<ul>'; inUl = true; }
        html += '<li>' + inline(t.replace(/^[-*]\s+/, '')) + '</li>'; continue;
      }
      if (/^\d+[.)]\s+/.test(t)) {
        if (!inOl) { closeLists(); html += '<ol>'; inOl = true; }
        html += '<li>' + inline(t.replace(/^\d+[.)]\s+/, '')) + '</li>'; continue;
      }
      closeLists();
      if (t === '') continue;
      html += '<p>' + inline(t) + '</p>';
    }
    closeLists(); flushTable();
    return html;
  }

  // ── charts ───────────────────────────────────────────────────────────
  // Tiny inline sparkline — smooth curve with a soft area fill.
  function sparkline(points, w = 110, h = 26) {
    if (!points || !points.length) return `<svg width="${w}" height="${h}"></svg>`;
    const max = Math.max(...points, 1), min = Math.min(...points, 0);
    const span = max - min || 1;
    const step = w / Math.max(points.length - 1, 1);
    const pts = points.map((p, i) => [+(i * step).toFixed(1), +(h - 2 - ((p - min) / span) * (h - 4)).toFixed(1)]);
    const d = smoothPath(pts, 1, h - 1);
    const area = d + ` L${pts[pts.length - 1][0]},${h - 1} L${pts[0][0]},${h - 1} Z`;
    return `<svg width="${w}" height="${h}" aria-hidden="true">
      <path d="${area}" fill="var(--series-1)" opacity="0.12"/>
      <path d="${d}" fill="none" stroke="var(--series-1)" stroke-width="1.5" stroke-linecap="round"/>
    </svg>`;
  }

  // Metric switcher for trend charts: Sessions | Users | Page views | Conversions.
  const chartPrefs = {};
  function initMetricChart(id, trend, markers = [], height = 260, onDay = null) {
    const all = [
      ['sessions', 'Sessions'], ['totalUsers', 'Users'],
      ['screenPageViews', 'Page views'], ['conversions', 'Conversions'],
    ].filter(([k]) => trend.some((r) => r[k] != null));
    if (!chartPrefs[id] || !all.some(([k]) => k === chartPrefs[id])) chartPrefs[id] = 'sessions';
    const tg = document.getElementById(id + '-toggle');
    if (tg) {
      tg.innerHTML = all.map(([k, l]) => `<button data-k="${k}" class="${chartPrefs[id] === k ? 'active' : ''}">${l}</button>`).join('');
      tg.querySelectorAll('button').forEach((b) =>
        b.addEventListener('click', () => { chartPrefs[id] = b.dataset.k; initMetricChart(id, trend, markers, height, onDay); })
      );
    }
    const label = all.find(([k]) => k === chartPrefs[id])?.[1] || 'Sessions';
    trendChart(document.getElementById(id), trend, chartPrefs[id], label, height, markers, onDay);
  }

  function barList(rows, valueKey, labelKey = 'dimensionValue', format = fmtInt) {
    const max = Math.max(1, ...rows.map((r) => r[valueKey] || 0));
    return rows.map((r) => `
      <div class="bar-row" title="${esc(r[labelKey])}: ${format(r[valueKey])}">
        <div class="bar-label">${esc(r[labelKey])}</div>
        <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r[valueKey] || 0)) / max}%"></div></div>
        <div class="bar-value">${format(r[valueKey])}</div>
      </div>`).join('');
  }

  // Devices bar list with per-device drill-down.
  function deviceBars(rows) {
    const max = Math.max(1, ...rows.map((r) => r.sessions || 0));
    return rows.map((r) => `
      <div class="bar-row clickable" data-dev="${esc(r.dimensionValue)}" title="Drill into ${esc(r.dimensionValue)}">
        <div class="bar-label">${esc(r.dimensionValue)}</div>
        <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r.sessions || 0)) / max}%"></div></div>
        <div class="bar-value">${fmtInt(r.sessions)}</div>
      </div>`).join('');
  }

  async function renderDeviceModal(siteId, device) {
    const body = openModal(`<h3>📱 ${esc(device)}</h3><div class="note">Loading device breakdown…</div>`);
    try {
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/device?device=${encodeURIComponent(device)}&range=${encodeURIComponent(state.range)}`);
      const tot = (d.channels || []).reduce((a, c) => a + (c.sessions || 0), 0);
      body.innerHTML = `
        <h3 style="margin-bottom:8px">📱 ${esc(device)} — this period</h3>
        <div class="facts-row" style="margin-bottom:10px"><span class="fact-pill">👥 <b>${fmtInt(tot)}</b> sessions</span></div>
        ${d.channels?.length ? `<h3>Channels on ${esc(device)}</h3>${barList(d.channels, 'sessions')}` : ''}
        ${d.sources?.length ? `<h3 style="margin-top:12px">Top sources</h3>${barList(d.sources, 'sessions')}` : ''}
        ${d.pages?.length ? `<h3 style="margin-top:12px">Top pages</h3>${barList(d.pages, 'screenPageViews')}` : ''}`;
    } catch (err) {
      body.innerHTML = `<h3>📱 ${esc(device)}</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  function niceCeil(v) {
    const pow = Math.pow(10, Math.floor(Math.log10(v || 1)));
    for (const m of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) if (m * pow >= v) return m * pow;
    return 10 * pow;
  }

  // Smooth monotone-ish curve through points (Catmull-Rom → bezier, clamped).
  function smoothPath(pts, yMin, yMax) {
    if (pts.length < 3) return 'M' + pts.map((p) => p.join(',')).join(' L');
    const cl = (v) => Math.max(yMin, Math.min(yMax, v));
    let d = `M${pts[0][0]},${pts[0][1]}`;
    for (let i = 0; i < pts.length - 1; i++) {
      const p0 = pts[Math.max(0, i - 1)], p1 = pts[i], p2 = pts[i + 1], p3 = pts[Math.min(pts.length - 1, i + 2)];
      const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = cl(p1[1] + (p2[1] - p0[1]) / 6);
      const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = cl(p2[1] - (p3[1] - p1[1]) / 6);
      d += `C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0]},${p2[1]}`;
    }
    return d;
  }

  let __gid = 0;

  // Single-series area/line chart — smooth curve, gradient fill, soft glow,
  // hover crosshair + tooltip, optional event markers.
  function trendChart(el, data, key = 'sessions', name = 'Sessions', H0 = 260, markers = [], onDay = null) {
    if (!data.length) { el.innerHTML = '<div class="note">No data.</div>'; return; }
    const W = Math.max(el.clientWidth || 720, 320);
    // Markers with `anno` get Hyundai-style callout labels — reserve headroom.
    const annoMarkers = markers.filter((m) => m.anno && data.some((d) => d.date === m.date));
    // Titles on a brand site all start with the same word ("Hyundai …") — strip
    // that shared prefix from the display so the distinctive part stays visible.
    let annoPrefix = '';
    if (annoMarkers.length >= 5) {
      const firsts = annoMarkers.map((m) => (String(m.anno).match(/^([A-Za-z][\w'&-]*)\s+\S/) || [])[1]).filter(Boolean);
      const counts = {};
      firsts.forEach((f2) => { counts[f2] = (counts[f2] || 0) + 1; });
      const [bestWord, n] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || [];
      if (bestWord && n >= annoMarkers.length * 0.6) annoPrefix = bestWord + ' ';
    }
    const annoText = (m) => {
      const s = String(m.anno);
      return annoPrefix && s.startsWith(annoPrefix) ? s.slice(annoPrefix.length) : s;
    };
    // More peaks → more label lanes, keeping labels long enough to read.
    const laneCount = annoMarkers.length > 8 ? 6 : annoMarkers.length > 4 ? 4 : 3;
    const annoLen = annoMarkers.length > 8 ? 26 : annoMarkers.length > 4 ? 30 : 34;
    const annoPad = annoMarkers.length ? 10 + laneCount * 15 : 0;
    const H = H0 + annoPad;
    const padL = 48, padR = 14, padT = 14 + annoPad, padB = 26;
    const vals = data.map((d) => d[key] || 0);
    const top = niceCeil(Math.max(...vals, 1));
    const x = (i) => padL + (W - padL - padR) * (data.length === 1 ? 0.5 : i / (data.length - 1));
    const y = (v) => padT + (H - padT - padB) * (1 - v / top);
    const pts = vals.map((v, i) => [+x(i).toFixed(1), +y(v).toFixed(1)]);
    const line = smoothPath(pts, padT, y(0));
    // Lay the callouts out in up to 3 lanes so labels never collide.
    const laneEnd = [];
    const callouts = annoMarkers.map((m) => {
      const i = data.findIndex((d) => d.date === m.date);
      const px = x(i), py = y(vals[i]);
      const full = annoText(m);
      const text = full.length > annoLen ? full.slice(0, annoLen - 1) + '…' : full;
      const w = text.length * 6.4 + 10;
      let anchor = 'middle', tx = px;
      if (px - w / 2 < padL) { anchor = 'start'; tx = padL; }
      else if (px + w / 2 > W - padR) { anchor = 'end'; tx = W - padR; }
      const x0 = anchor === 'middle' ? tx - w / 2 : anchor === 'start' ? tx : tx - w;
      let lane = 0;
      while (lane < laneCount - 1 && (laneEnd[lane] ?? -1e9) > x0 - 10) lane++;
      laneEnd[lane] = x0 + w;
      const ty = 15 + lane * 15;
      return `<g style="cursor:default">
        <line x1="${px.toFixed(1)}" x2="${px.toFixed(1)}" y1="${ty + 4}" y2="${(py - 6).toFixed(1)}" stroke="var(--text-primary)" stroke-width="1" opacity="0.5"/>
        <circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="3.6" fill="var(--surface-1)" stroke="var(--series-1)" stroke-width="2.2"/>
        <text x="${tx.toFixed(1)}" y="${ty}" text-anchor="${anchor}" font-size="11.5" font-weight="700" fill="var(--text-primary)" style="paint-order:stroke" stroke="var(--surface-1)" stroke-width="3.5">${esc(text)}<title>${esc(m.label || m.anno)}</title></text>
      </g>`;
    }).join('');
    const area = line + ` L${pts[pts.length - 1][0]},${y(0).toFixed(1)} L${pts[0][0]},${y(0).toFixed(1)} Z`;
    const gid = 'lpg' + (__gid++);
    let grid = '', ylab = '';
    for (let g = 1; g <= 4; g++) {
      const gv = (top * g) / 4, gy = y(gv);
      grid += `<line x1="${padL}" x2="${W - padR}" y1="${gy}" y2="${gy}" stroke="var(--grid)" stroke-width="1" stroke-dasharray="2 5"/>`;
      ylab += `<text x="${padL - 9}" y="${gy + 3.5}" text-anchor="end" font-size="10.5" fill="var(--text-muted)">${kfmt(gv)}</text>`;
    }
    ylab += `<text x="${padL - 9}" y="${y(0) + 3.5}" text-anchor="end" font-size="10.5" fill="var(--text-muted)">0</text>`;
    const nTicks = Math.min(5, data.length);
    const tickIdx = [...new Set(Array.from({ length: nTicks }, (_, k) => Math.round((k * (data.length - 1)) / Math.max(1, nTicks - 1))))];
    const xlab = tickIdx.map((i) =>
      `<text x="${x(i)}" y="${H - 7}" text-anchor="${i === 0 ? 'start' : i === data.length - 1 ? 'end' : 'middle'}" font-size="10.5" fill="var(--text-muted)">${esc(data[i].date.slice(5))}</text>`
    ).join('');
    el.innerHTML = `
      <div class="chart-wrap">
        <svg viewBox="0 0 ${W} ${H}" height="${H}" preserveAspectRatio="none">
          <defs>
            <linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stop-color="var(--series-1)" stop-opacity="0.32"/>
              <stop offset="55%" stop-color="var(--series-1)" stop-opacity="0.08"/>
              <stop offset="100%" stop-color="var(--series-1)" stop-opacity="0"/>
            </linearGradient>
          </defs>
          ${grid}${ylab}${xlab}
          <line x1="${padL}" x2="${W - padR}" y1="${y(0)}" y2="${y(0)}" stroke="var(--baseline)" stroke-width="1"/>
          <path d="${area}" fill="url(#${gid})"/>
          <path d="${line}" fill="none" stroke="var(--series-1)" stroke-width="7" opacity="0.13" stroke-linecap="round"/>
          <path d="${line}" fill="none" stroke="var(--series-1)" stroke-width="2.25" stroke-linecap="round"/>
          ${markers.map((m) => {
            if (m.anno) return ''; // rendered as a callout instead
            const i = data.findIndex((d) => d.date === m.date);
            if (i === -1) return '';
            const yOff = m.row ? 15 : 0; // second marker row so email + peak don't overlap
            return `<g><line x1="${x(i).toFixed(1)}" x2="${x(i).toFixed(1)}" y1="${padT + 16 + yOff}" y2="${H - padB}" stroke="${m.color || 'var(--series-2)'}" stroke-width="1" stroke-dasharray="2 3" opacity="0.6"/>
              <text x="${x(i).toFixed(1)}" y="${padT + 12 + yOff}" text-anchor="middle" font-size="12" style="cursor:default">${m.icon || '📧'}<title>${esc(m.label || m.date)}</title></text></g>`;
          }).join('')}
          ${callouts}
          <g class="xh" style="display:none">
            <line y1="${padT}" y2="${H - padB}" x1="0" x2="0" stroke="var(--text-muted)" stroke-dasharray="3 3" stroke-width="1"/>
            <circle r="4.5" fill="var(--series-1)" stroke="var(--surface-1)" stroke-width="2"/>
          </g>
        </svg>
        <div class="chart-tip"></div>
      </div>`;
    const wrap = el.querySelector('.chart-wrap');
    const svg = wrap.querySelector('svg');
    const xh = wrap.querySelector('.xh');
    const xhLine = xh.querySelector('line');
    const xhDot = xh.querySelector('circle');
    const tip = wrap.querySelector('.chart-tip');
    svg.addEventListener('mousemove', (ev) => {
      const rect = svg.getBoundingClientRect();
      const sx = ((ev.clientX - rect.left) / rect.width) * W;
      const frac = (sx - padL) / (W - padL - padR);
      const i = Math.min(data.length - 1, Math.max(0, Math.round(frac * (data.length - 1))));
      const cx = x(i), cy = y(vals[i]);
      xh.style.display = '';
      xhLine.setAttribute('x1', cx); xhLine.setAttribute('x2', cx);
      xhDot.setAttribute('cx', cx); xhDot.setAttribute('cy', cy);
      tip.innerHTML = `<div class="t-date">${esc(data[i].date)}</div><div class="t-row"><span class="t-swatch"></span>${esc(name)}: <b>${fmtInt(vals[i])}</b></div>`;
      tip.style.display = 'block';
      const px = (cx / W) * rect.width;
      const flip = px > rect.width - 150;
      tip.style.left = flip ? '' : (px + 12) + 'px';
      tip.style.right = flip ? (rect.width - px + 12) + 'px' : '';
      tip.style.top = Math.max(4, (cy / H) * rect.height - 30) + 'px';
    });
    svg.addEventListener('mouseleave', () => { xh.style.display = 'none'; tip.style.display = 'none'; });
    if (onDay) {
      svg.style.cursor = 'pointer';
      svg.addEventListener('click', (ev) => {
        const rect = svg.getBoundingClientRect();
        const sx = ((ev.clientX - rect.left) / rect.width) * W;
        const frac = (sx - padL) / (W - padL - padR);
        const i = Math.min(data.length - 1, Math.max(0, Math.round(frac * (data.length - 1))));
        onDay(data[i].date);
      });
    }
  }

  // Day dossier — click a day on a traffic chart to see what happened.
  async function renderDayModal(scope, date) {
    const body = openModal(`<h3>📅 ${esc(date)}</h3><div class="note">Loading that day…</div>`);
    try {
      const r = `${date}..${date}`;
      if (scope === 'all') {
        const d = await api(`/api/portfolio?range=${encodeURIComponent(r)}`);
        body.innerHTML = `
          <h3 style="margin-bottom:8px">📅 ${esc(date)} — all sites</h3>
          <div class="facts-row" style="margin-bottom:10px">
            <span class="fact-pill">👥 <b>${fmtInt(d.totals.sessions)}</b> sessions</span>
            <span class="fact-pill">🙋 <b>${fmtInt(d.totals.totalUsers)}</b> users</span>
            <span class="fact-pill">📄 <b>${fmtInt(d.totals.screenPageViews)}</b> page views</span>
          </div>
          ${d.channels?.length ? `<h3>Channels that day</h3>${barList(d.channels.slice(0, 6), 'sessions')}` : ''}
          <h3 style="margin-top:12px">Sites that day</h3>
          ${barList(d.sites.sort((a, b) => b.sessions - a.sessions).slice(0, 10).map((s) => ({ dimensionValue: s.name, sessions: s.sessions })), 'sessions')}`;
      } else {
        const d = await api(`/api/site/${encodeURIComponent(scope)}?range=${encodeURIComponent(r)}`);
        body.innerHTML = `
          <h3 style="margin-bottom:8px">📅 ${esc(date)}</h3>
          <div class="facts-row" style="margin-bottom:10px">
            <span class="fact-pill">👥 <b>${fmtInt(d.totals.sessions)}</b> sessions</span>
            <span class="fact-pill">🙋 <b>${fmtInt(d.totals.totalUsers)}</b> users</span>
            <span class="fact-pill">📄 <b>${fmtInt(d.totals.screenPageViews)}</b> page views</span>
            <span class="fact-pill">⏱ <b>${fmtDur(d.totals.avgSessionDuration)}</b> avg session</span>
          </div>
          ${d.pages?.length ? `<h3>Top pages that day</h3>${barList(d.pages.slice(0, 8), 'screenPageViews')}` : ''}
          ${d.channels?.length ? `<h3 style="margin-top:12px">Channels that day</h3>${barList(d.channels.slice(0, 6), 'sessions')}` : ''}`;
      }
    } catch (err) {
      body.innerHTML = `<h3>📅 ${esc(date)}</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // Combo chart for content-vs-audience: sessions as a smooth line (left axis)
  // + releases published / email distributions as grouped bars (right axis).
  function cadenceChart(el, days, onDay = null, labels = { line: 'Sessions', bar1: 'Releases published', bar2: 'Email distributions', axisL: 'sessions' }) {
    if (!el) return;
    if (!days.length) { el.innerHTML = '<div class="note">No data.</div>'; return; }
    const W = Math.max(el.clientWidth || 720, 320), H = 210;
    const padL = 48, padR = 34, padT = 30, padB = 26;
    const hasSessions = days.some((d) => d.sessions > 0);
    const hasDist = days.some((d) => d.distributed > 0);
    const sTop = niceCeil(Math.max(...days.map((d) => d.sessions), 1));
    const maxC = Math.max(...days.map((d) => Math.max(d.published, d.distributed)), 1);
    const cTop = 4 * Math.ceil(maxC / 4); // multiple of 4 so all gridline labels are whole numbers
    const x = (i) => padL + (W - padL - padR) * (days.length === 1 ? 0.5 : i / (days.length - 1));
    const yS = (v) => padT + (H - padT - padB) * (1 - v / sTop);
    const yC = (v) => padT + (H - padT - padB) * (1 - v / cTop);
    const y0 = yS(0);
    const slotW = (W - padL - padR) / Math.max(1, days.length - 1);
    const bw = Math.max(2, Math.min(9, slotW * 0.3));
    let grid = '', ylabL = '', ylabR = '';
    for (let g = 1; g <= 4; g++) {
      const gy = yS((sTop * g) / 4);
      grid += `<line x1="${padL}" x2="${W - padR}" y1="${gy}" y2="${gy}" stroke="var(--grid)" stroke-width="1" stroke-dasharray="2 5"/>`;
      if (hasSessions) ylabL += `<text x="${padL - 9}" y="${gy + 3.5}" text-anchor="end" font-size="10.5" fill="var(--text-muted)">${kfmt((sTop * g) / 4)}</text>`;
      ylabR += `<text x="${W - padR + 9}" y="${gy + 3.5}" text-anchor="start" font-size="10.5" fill="var(--text-muted)">${(cTop * g) / 4}</text>`;
    }
    const nTicks = Math.min(5, days.length);
    const tickIdx = [...new Set(Array.from({ length: nTicks }, (_, k) => Math.round((k * (days.length - 1)) / Math.max(1, nTicks - 1))))];
    const xlab = tickIdx.map((i) =>
      `<text x="${x(i)}" y="${H - 7}" text-anchor="${i === 0 ? 'start' : i === days.length - 1 ? 'end' : 'middle'}" font-size="10.5" fill="var(--text-muted)">${esc(String(days[i].date).slice(5))}</text>`
    ).join('');
    const bars = days.map((d, i) => {
      const cx = x(i);
      let s = '';
      if (d.published > 0) {
        const h = y0 - yC(d.published);
        s += `<rect x="${(cx - (hasDist ? bw + 0.75 : bw / 2)).toFixed(1)}" y="${(y0 - h).toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="1.5" fill="var(--series-2)" opacity="0.85"/>`;
      }
      if (hasDist && d.distributed > 0) {
        const h = y0 - yC(d.distributed);
        s += `<rect x="${(cx + 0.75).toFixed(1)}" y="${(y0 - h).toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="1.5" fill="var(--series-3)" opacity="0.85"/>`;
      }
      const tipTxt = `${String(d.date)} — ${d.published} ${labels.bar1.toLowerCase()}${hasDist ? ` · ${d.distributed} ${labels.bar2.toLowerCase()}` : ''}${hasSessions ? ` · ${fmtInt(d.sessions)} ${labels.line.toLowerCase()}` : ''}${onDay ? ' — click for detail' : ''}`;
      s += `<rect class="cad-hit" data-di="${i}" x="${(cx - slotW / 2).toFixed(1)}" y="${padT}" width="${slotW.toFixed(1)}" height="${H - padT - padB}" fill="transparent" ${onDay ? 'style="cursor:pointer"' : ''}><title>${esc(tipTxt)}</title></rect>`;
      return s;
    }).join('');
    let lineSvg = '';
    const gid = 'lpg' + (__gid++);
    if (hasSessions) {
      const pts = days.map((d, i) => [+x(i).toFixed(1), +yS(d.sessions).toFixed(1)]);
      const line = smoothPath(pts, padT, y0);
      const area = line + ` L${pts[pts.length - 1][0]},${y0.toFixed(1)} L${pts[0][0]},${y0.toFixed(1)} Z`;
      lineSvg = `<path d="${area}" fill="url(#${gid})"/>
        <path d="${line}" fill="none" stroke="var(--series-1)" stroke-width="6" opacity="0.13" stroke-linecap="round"/>
        <path d="${line}" fill="none" stroke="var(--series-1)" stroke-width="2" stroke-linecap="round"/>`;
    }
    const leg = (sw, label) => `<span style="display:inline-flex;align-items:center;gap:6px">${sw}${label}</span>`;
    el.innerHTML = `
      <div style="display:flex;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--text-secondary);margin-bottom:2px">
        ${hasSessions ? leg('<span style="width:16px;height:3px;border-radius:2px;background:var(--series-1)"></span>', esc(labels.line)) : ''}
        ${leg('<span style="width:9px;height:9px;border-radius:2px;background:var(--series-2)"></span>', esc(labels.bar1))}
        ${hasDist ? leg('<span style="width:9px;height:9px;border-radius:2px;background:var(--series-3)"></span>', esc(labels.bar2)) : ''}
      </div>
      <svg viewBox="0 0 ${W} ${H}" height="${H}" width="100%" preserveAspectRatio="none">
        <defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="var(--series-1)" stop-opacity="0.22"/>
          <stop offset="60%" stop-color="var(--series-1)" stop-opacity="0.05"/>
          <stop offset="100%" stop-color="var(--series-1)" stop-opacity="0"/>
        </linearGradient></defs>
        ${grid}${ylabL}${ylabR}${xlab}
        <line x1="${padL}" x2="${W - padR}" y1="${y0}" y2="${y0}" stroke="var(--baseline)" stroke-width="1"/>
        ${bars}${lineSvg}
        ${hasSessions ? `<text x="4" y="${padT - 12}" text-anchor="start" font-size="10" fill="var(--text-muted)">${esc(labels.axisL)}</text>` : ''}
        <text x="${W - 4}" y="${padT - 12}" text-anchor="end" font-size="10" fill="var(--text-muted)">per day</text>
      </svg>`;
    if (onDay) {
      el.querySelectorAll('.cad-hit').forEach((r) =>
        r.addEventListener('click', () => onDay(days[Number(r.dataset.di)].date))
      );
    }
  }

  function tile(label, value, d, invert = false) {
    let cls = 'flat', arrow = '·', text = 'no change';
    if (d != null && isFinite(d) && Math.abs(d) >= 0.001) {
      const good = invert ? d < 0 : d > 0;
      cls = good ? 'up' : 'down';
      arrow = d > 0 ? '↑' : '↓';
      text = Math.abs(d * 100).toFixed(1) + '%';
    }
    return `<div class="card tile">
      <div class="label">${esc(label)}</div>
      <div class="value">${value}</div>
      <div class="delta ${cls}" title="vs previous period">${arrow} ${text}</div>
    </div>`;
  }

  // ── shell ────────────────────────────────────────────────────────────
  const root = document.getElementById('root');

  function route() {
    if (state.portal) return { page: 'site', id: state.portal.siteId };
    if (location.hash.startsWith('#/reports')) return { page: 'reports' };
    if (location.hash.startsWith('#/users')) return { page: 'users' };
    if (location.hash.startsWith('#/sites-admin')) return { page: 'sites-admin' };
    if (location.hash.startsWith('#/billing')) return { page: 'billing' };
    if (location.hash.startsWith('#/activity')) return { page: 'activity' };
    const m = /^#\/site\/([\w-]+)/.exec(location.hash);
    return m ? { page: 'site', id: m[1] } : { page: 'overview' };
  }
  const isAdminUser = () => state.user && (state.user.role === 'admin' || state.user.role === 'superadmin');

  // Read-only client portal — no sidebar, no chat, one site only.
  function renderPortalShell() {
    const s = state.portal.site;
    root.innerHTML = `
      <div class="layout">
        <div class="main">
          <div class="topbar">
            <div>
              <h1>${esc(s.name)}</h1>
              <div class="sub">${esc(s.url || '')} · analytics by SparkSoftware</div>
            </div>
            <div class="topbar-right">
              <div class="range-picker">
                ${RANGES.map(([k, label]) => `<button data-range="${k}" class="${state.range === k ? 'active' : ''}">${label}</button>`).join('')}
                <button data-range-custom class="${state.range.includes('..') ? 'active' : ''}">${state.range.includes('..') ? esc(state.range.replace('..', ' → ')) : '📅 Custom'}</button>
              </div>
            </div>
          </div>
          <div class="content" id="content"><div class="loading">Loading…</div></div>
        </div>
      </div>`;
    root.querySelectorAll('[data-range]').forEach((b) =>
      b.addEventListener('click', () => { state.range = b.dataset.range; renderPortalShell(); })
    );
    root.querySelectorAll('[data-range-custom]').forEach((b) =>
      b.addEventListener('click', () => openCustomRange())
    );
    loadContent();
  }

  // Custom date range — from/to pickers + one-click shortcuts.
  function openCustomRange() {
    const cur = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/.exec(state.range);
    const iso = (d) => d.toISOString().slice(0, 10);
    const today = new Date(); today.setUTCHours(0, 0, 0, 0);
    const yest = new Date(today); yest.setUTCDate(yest.getUTCDate() - 1);
    const daysAgo = (n) => { const d = new Date(today); d.setUTCDate(d.getUTCDate() - n); return d; };
    const lastMonthEnd = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0));
    const lastMonthStart = new Date(Date.UTC(lastMonthEnd.getUTCFullYear(), lastMonthEnd.getUTCMonth(), 1));
    const q = Math.floor(today.getUTCMonth() / 3);
    const qStart = new Date(Date.UTC(today.getUTCFullYear(), q * 3, 1));
    const SHORTCUTS = [
      ['Yesterday', iso(yest), iso(yest)],
      ['Last 14 days', iso(daysAgo(14)), iso(yest)],
      ['Last month', iso(lastMonthStart), iso(lastMonthEnd)],
      ['This quarter', iso(qStart), iso(yest)],
      ['Year to date', `${today.getUTCFullYear()}-01-01`, iso(yest)],
    ];
    const body = openModal(`
      <h3>Custom date range</h3>
      <div class="chat-suggest" style="padding:8px 0 12px">
        ${SHORTCUTS.map(([l, f, t], i) => `<button data-sc="${i}">${esc(l)}</button>`).join('')}
      </div>
      <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
        <input class="auth-in" type="date" id="cr-from" value="${cur ? cur[1] : iso(daysAgo(28))}" style="width:auto" />
        <span class="note">to</span>
        <input class="auth-in" type="date" id="cr-to" value="${cur ? cur[2] : iso(yest)}" style="width:auto" />
        <button class="btn-primary" id="cr-apply">Apply</button>
      </div>
      <div class="note" style="margin-top:10px">Applies everywhere — every card, chart, insight and drill-down follows this range.</div>`);
    const apply = (f, t) => {
      if (!f || !t || f > t) return;
      state.range = `${f}..${t}`;
      closeModal();
      refresh();
    };
    body.querySelectorAll('[data-sc]').forEach((b) =>
      b.addEventListener('click', () => apply(...SHORTCUTS[Number(b.dataset.sc)].slice(1)))
    );
    body.querySelector('#cr-apply').addEventListener('click', () =>
      apply(body.querySelector('#cr-from').value, body.querySelector('#cr-to').value)
    );
  }

  function renderShell() {
    if (state.portal) return renderPortalShell();
    const r = route();
    const c = state.config;
    const site = r.page === 'site' ? c.sites.find((s) => s.id === r.id) : null;
    const anyDemo = c.sites.some((s) => s.demo);
    const navMin = localStorage.getItem('lp-nav') === 'min';
    root.innerHTML = `
      <div class="layout ${navMin ? 'nav-min' : ''}">
        <nav class="sidebar">
          <div class="brand"><span class="logo-mark">${LOGO}</span><div class="nav-lbl">Spark<span>Software</span><small>Reporting</small></div>
            <button class="nav-collapse" id="nav-toggle" title="${navMin ? 'Expand menu' : 'Collapse menu'}">${navMin ? '»' : '«'}</button>
          </div>
          <div class="nav-section">
            <a href="#/" class="nav-item ${r.page === 'overview' ? 'active' : ''}" title="${c.sites.length > 1 ? 'All sites' : 'Overview'}"><span class="nav-ic">⌂</span><span class="nav-lbl">${c.sites.length > 1 ? 'All sites' : 'Overview'}</span></a>
            ${isAdminUser() ? `
              <a href="#/reports" class="nav-item ${r.page === 'reports' ? 'active' : ''}" title="Reports"><span class="nav-ic">▤</span><span class="nav-lbl">Reports</span></a>
              <a href="#/users" class="nav-item ${r.page === 'users' ? 'active' : ''}" title="Users"><span class="nav-ic">◉</span><span class="nav-lbl">Users</span></a>
              <a href="#/sites-admin" class="nav-item ${r.page === 'sites-admin' ? 'active' : ''}" title="Sites"><span class="nav-ic">✦</span><span class="nav-lbl">Sites</span></a>
              ${state.user?.role === 'superadmin' ? `<a href="#/activity" class="nav-item ${r.page === 'activity' ? 'active' : ''}" title="Activity &amp; error logs"><span class="nav-ic">☰</span><span class="nav-lbl">Activity</span></a>
              <a href="#/billing" class="nav-item ${r.page === 'billing' ? 'active' : ''}" title="Billing"><span class="nav-ic">▦</span><span class="nav-lbl">Billing</span></a>` : ''}` : ''}
          </div>
          <div class="nav-section" style="padding-top:0">
            <div class="nav-label nav-lbl">Websites</div>
            ${c.sites.map((s) => `
              <a href="#/site/${esc(s.id)}" class="nav-item ${site?.id === s.id ? 'active' : ''}" title="${esc(s.name)}">
                <span class="chip c${[...s.id].reduce((a, ch) => a + ch.charCodeAt(0), 0) % 3}">${esc(initials(s.name))}</span> <span class="nav-lbl">${esc(s.name)}</span>
              </a>`).join('')}
          </div>
        </nav>
        <div class="nav-scrim" id="nav-scrim"></div>
        <div class="main">
          <div class="topbar">
            <button class="nav-burger" id="nav-burger" title="Menu" aria-label="Open menu">☰</button>
            <div style="flex:1;min-width:0">
              <h1>${site ? esc(site.name) : r.page === 'reports' ? 'Client reports' : r.page === 'users' ? 'User management' : r.page === 'sites-admin' ? 'Site configuration' : r.page === 'billing' ? 'Plan & billing' : 'Portfolio overview'}</h1>
              <div class="sub">
                ${site ? esc(site.url) : r.page === 'reports' ? 'Generate & download client-ready reports' : r.page === 'users' ? 'Approve, assign and manage accounts' : r.page === 'sites-admin' ? 'Add and edit websites without touching config files' : r.page === 'billing' ? 'Your subscription, invoices and payment details' : c.sites.length + ' websites · combined Google Analytics'}
                ${site && state.gaMarket[site.id] ? ` <span class="crumb">🌍 ${esc(state.gaMarketLabels[site.id] || state.gaMarket[site.id].split(',').join(' + '))}</span>` : ''}
                ${anyDemo ? ' <span class="demo-badge">demo data</span>' : ''}
              </div>
            </div>
            <div class="topbar-right">
              <div class="range-picker">
                ${RANGES.map(([k, label]) => `<button data-range="${k}" class="${state.range === k ? 'active' : ''}">${label}</button>`).join('')}
                <button data-range-custom class="${state.range.includes('..') ? 'active' : ''}">${state.range.includes('..') ? esc(state.range.replace('..', ' → ')) : '📅 Custom'}</button>
              </div>
              ${site || r.page === 'overview' ? '<button class="chat-toggle" id="dl-graphs" title="Pick which graphs to download as images — or grab them all as one zip (media monitoring included)">⬇ Graphs</button>' : ''}
              ${site?.monitoring && !site?.monitoringOnly ? '<button class="chat-toggle" id="jump-mon" title="Jump straight to the Media monitoring section — no scrolling">📡 Monitoring</button>' : ''}
              ${site && isAdminUser() ? '<button class="chat-toggle" id="share-btn" title="Copy a read-only link for this client">🔗 Client link</button>' : ''}
              ${state.config.chatConfigured !== false ? `<button class="chat-toggle" id="chat-open" style="${state.chatOpen ? 'display:none' : ''}"><span class="spark">✦</span> Ask AI</button>` : ''}
              <button class="chat-toggle" id="theme-btn" title="Switch theme">${themeIcon()}</button>
              <button class="chat-toggle" id="user-chip" title="${esc(state.user?.email || '')}">${esc(initials(state.user?.name || 'U'))}</button>
            </div>
          </div>
          ${(() => {
            const lic = state.config?.license;
            if (!lic) return '';
            if (lic.mode === 'trial') return `<div style="background:var(--series-1);color:#fff;font-size:12.5px;font-weight:600;padding:7px 18px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">⏳ Free trial — <b>${lic.daysLeft} day${lic.daysLeft === 1 ? '' : 's'} left</b>. <a href="https://spark-software.de/reporting#pricing" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline">Get a licence (yearly saves 15%)</a></div>`;
            if (lic.warning && isAdminUser()) return `<div style="background:var(--warn-text);color:#fff;font-size:12.5px;font-weight:600;padding:7px 18px">⚠ ${esc(lic.warning)}</div>`;
            return '';
          })()}
          <div class="content" id="content"><div class="loading">Loading…</div></div>
        </div>
        <div id="chat-slot"></div>
      </div>`;
    root.querySelectorAll('[data-range]').forEach((b) =>
      b.addEventListener('click', () => { state.range = b.dataset.range; refresh(); })
    );
    root.querySelectorAll('[data-range-custom]').forEach((b) =>
      b.addEventListener('click', () => openCustomRange())
    );
    document.getElementById('chat-open')?.addEventListener('click', () => { state.chatOpen = true; renderChat(); });
    const dlBtn = document.getElementById('dl-graphs');
    if (dlBtn) dlBtn.addEventListener('click', () => openGraphPicker());
    const jumpMon = document.getElementById('jump-mon');
    if (jumpMon) jumpMon.addEventListener('click', () => {
      const t = document.getElementById('monitoring-cards');
      if (t && t.firstElementChild) t.firstElementChild.scrollIntoView({ behavior: 'smooth', block: 'start' });
      else if (t) t.scrollIntoView({ behavior: 'smooth', block: 'start' });
    });
    // (The analytics market picker now lives INSIDE the analytics section —
    // rendered per site by renderGaMarketBar(), so it can never carry one
    // client's markets onto another and disappears for monitoring-only sites.)
    // Mobile drawer: burger opens the sidebar, scrim / navigating closes it.
    const layoutEl = root.querySelector('.layout');
    document.getElementById('nav-burger')?.addEventListener('click', () => layoutEl.classList.toggle('nav-open'));
    document.getElementById('nav-scrim')?.addEventListener('click', () => layoutEl.classList.remove('nav-open'));
    root.querySelectorAll('.sidebar .nav-item').forEach((a) =>
      a.addEventListener('click', () => layoutEl.classList.remove('nav-open'))
    );
    document.getElementById('nav-toggle')?.addEventListener('click', () => {
      const layout = document.querySelector('.layout');
      const min = layout.classList.toggle('nav-min');
      localStorage.setItem('lp-nav', min ? 'min' : '');
      const btn = document.getElementById('nav-toggle');
      btn.textContent = min ? '»' : '«';
      btn.title = min ? 'Expand menu' : 'Collapse menu';
    });
    document.getElementById('theme-btn')?.addEventListener('click', () => {
      const order = ['light', 'dark', 'auto'];
      const cur = localStorage.getItem('lp-theme') || 'light';
      const next = order[(order.indexOf(cur) + 1) % order.length];
      applyTheme(next);
      const btn = document.getElementById('theme-btn');
      if (btn) { btn.innerHTML = themeIcon(); btn.title = `Theme: ${next} — click to switch`; }
    });
    document.getElementById('user-chip')?.addEventListener('click', () => {
      const u = state.user || {};
      const body = openModal(`
        <h3 style="margin-bottom:8px">Account</h3>
        <div class="facts-row" style="margin-bottom:12px">
          <span class="fact-pill"><b>${esc(u.name || '')}</b></span>
          <span class="fact-pill">${esc(u.email || '')}</span>
          <span class="fact-pill">Role: <b>${esc(u.role || '')}</b></span>
        </div>
        <button class="btn-primary" id="do-logout">Sign out</button>`);
      body.querySelector('#do-logout').addEventListener('click', () => { closeModal(); logout(); });
    });
    const shareBtn = document.getElementById('share-btn');
    if (shareBtn)
      shareBtn.addEventListener('click', async () => {
        try {
          const d = await api(`/api/share-link?site=${encodeURIComponent(route().id)}`);
          const full = location.origin + d.url;
          const siteCfg2 = (state.config?.sites || []).find((x) => x.id === route().id);
          if (siteCfg2?.monitoring) {
            // Two shareable views: everything, or just the coverage report.
            const body = openModal(`<h3>Client link</h3>
              <div class="note" style="margin-bottom:10px">Read-only links for this client — no login needed. Pick which view to share.</div>
              <div style="display:grid;gap:8px">
                <button class="btn-primary" id="cl-full">🔗 Copy FULL dashboard link<div class="note" style="color:inherit;opacity:0.8;font-weight:400">Analytics + platform + email + media monitoring</div></button>
                <button class="btn-primary" id="cl-mon" style="background:var(--series-2)">📡 Copy MEDIA MONITORING ONLY link<div class="note" style="color:inherit;opacity:0.8;font-weight:400">Just the coverage report — nothing else</div></button>
              </div>`);
            const copy = async (u, btn, label) => {
              await navigator.clipboard.writeText(u);
              btn.textContent = '✓ Copied';
              setTimeout(() => closeModal(), 900);
            };
            body.querySelector('#cl-full').addEventListener('click', (e) => copy(full, e.currentTarget));
            body.querySelector('#cl-mon').addEventListener('click', (e) => copy(full + '?view=monitoring', e.currentTarget));
          } else {
            await navigator.clipboard.writeText(full);
            shareBtn.textContent = '✓ Link copied';
            setTimeout(() => (shareBtn.textContent = '🔗 Client link'), 2500);
          }
        } catch (err) {
          shareBtn.textContent = 'Copy failed';
          setTimeout(() => (shareBtn.textContent = '🔗 Client link'), 2500);
        }
      });
    renderChat();
    loadContent();
  }

  function refresh() { renderShell(); }

  // ── pages ────────────────────────────────────────────────────────────
  // ── Smart loader: dashboard-shaped skeleton + live status + tips ───────
  const LOAD_STAGES = [
    'Connecting to Google Analytics 4…',
    'Reading the platform reports database…',
    'Merging SparkPost & Brevo campaigns…',
    'Scanning the dated media event log…',
    'Reconciling platform vs GA4 numbers…',
    'Ranking releases, models & assets for your selection…',
    'Drawing the graphs…',
  ];
  const LOAD_TIPS = [
    'Tip: click any bar or row — almost every graph drills into the data behind it.',
    'Tip: the 🌍 button filters EVERY graph by market — dates and market work together.',
    'Tip: ✦ Ask AI reads GA4, the platform DB and email live — try "why did traffic spike?"',
    'Tip: ⬇ Graphs lets you pick any charts and download them as images in one zip.',
    'Tip: every list has a search box and a View-all popup with CSV export.',
    'Tip: install this dashboard as an app — Add to Home Screen on your phone.',
    'Tip: 🔗 Client link creates a read-only view you can share with a client.',
  ];
  function loadingHtml(label) {
    const tile = '<div class="card tile"><div class="skel-line" style="width:45%"></div><div class="skel-line skel-big" style="width:65%"></div><div class="skel-line" style="width:30%"></div></div>';
    const bar = (w) => `<div class="skel-row"><div class="skel-line" style="width:110px"></div><div class="skel-track"><div class="skel-fill" style="width:${w}%"></div></div></div>`;
    return `
      <div class="load-smart">
        <div class="load-status"><span class="chat-spin" style="width:13px;height:13px;flex:0 0 13px"></span><span class="load-status-txt">${esc(label || LOAD_STAGES[0])}</span></div>
        <div class="grid tiles skel-wrap">${Array(6).fill(tile).join('')}</div>
        <div class="card skel-wrap"><div class="skel-line" style="width:220px;margin-bottom:14px"></div>
          <div class="skel-chart">${Array.from({ length: 24 }, (_, i) => `<div class="skel-bar" style="height:${18 + Math.round(62 * Math.abs(Math.sin(i * 0.6)))}%;animation-delay:${i * 60}ms"></div>`).join('')}</div>
        </div>
        <div class="grid two">
          <div class="card skel-wrap"><div class="skel-line" style="width:180px;margin-bottom:14px"></div>${bar(92)}${bar(71)}${bar(55)}${bar(38)}${bar(24)}</div>
          <div class="card skel-wrap"><div class="skel-line" style="width:150px;margin-bottom:14px"></div>${bar(84)}${bar(66)}${bar(49)}${bar(31)}${bar(18)}</div>
        </div>
      </div>`;
  }
  function animateLoading(root2) {
    const txt = root2.querySelector('.load-status-txt');
    if (!txt) return;
    let i = 0;
    const iv = setInterval(() => {
      if (!document.contains(txt)) { clearInterval(iv); return; }
      i++;
      txt.textContent = i < LOAD_STAGES.length
        ? LOAD_STAGES[i]
        : LOAD_TIPS[(i - LOAD_STAGES.length) % LOAD_TIPS.length];
    }, 1500);
  }
  function showLoading(el, label) {
    el.innerHTML = loadingHtml(label);
    animateLoading(el);
  }

  // Keep a dropdown pop fully on screen: flip upward when there's more room
  // above the button, otherwise scroll it into view.
  function placePop(btn, pop) {
    // Open downward when the pop fits below; otherwise open in whichever
    // direction has more room and CAP the height to that room so the top
    // is never clipped off-screen (the old flip-up could push the region
    // list above the viewport at 100% zoom).
    pop.classList.remove('up');
    pop.style.maxHeight = '';
    requestAnimationFrame(() => {
      const rb = btn.getBoundingClientRect();
      const below = window.innerHeight - rb.bottom - 16;
      const above = rb.top - 16;
      const need = pop.scrollHeight + 6;
      if (need <= below) return; // fits below as-is
      if (need <= above && above > below) { pop.classList.add('up'); return; } // fits whole above
      // Fits nowhere whole → larger side, height-capped, inner scroll.
      if (above > below) {
        pop.classList.add('up');
        pop.style.maxHeight = Math.max(200, above) + 'px';
      } else {
        pop.style.maxHeight = Math.max(200, below) + 'px';
      }
    });
  }

  async function loadContent() {
    const el = document.getElementById('content');
    const r = route();
    // Load-generation token: every call supersedes the previous one, so a
    // rapid burst of range/market clicks paints ONLY the latest result and
    // superseded loads stop dead instead of stacking requests + repaints.
    const gen = (state.loadGen = (state.loadGen || 0) + 1);
    const live = () => gen === state.loadGen;
    // Per-client CI theme: on for that client's pages, off everywhere else.
    applyClientCi(r.id ? (state.config?.sites || []).find((x) => x.id === r.id)?.ci || null : null);
    if (el) showLoading(el); // skeleton + live status while everything fetches
    try {
      if (r.page === 'reports') { await renderReports(el); return; }
      if (r.page === 'users') { await renderUsers(el); return; }
      if (r.page === 'sites-admin') { await renderSitesAdmin(el); return; }
      if (r.page === 'billing') { await renderBilling(el); return; }
      if (r.page === 'activity') { await renderActivity(el); return; }
      if (r.page === 'overview') {
        const [data, sparks] = await Promise.all([
          api('/api/portfolio?range=' + encodeURIComponent(state.range)),
          api('/api/sparklines?range=' + encodeURIComponent(state.range)).catch(() => ({})),
        ]);
        if (!live()) return; // a newer load took over — don't paint stale data
        state.pageData = { data, sparks };
        renderOverview(el);
      } else {
        // Monitoring-only clients (media monitoring connected, no GA4 property
        // and no reports DB — or the explicit admin setting) — and
        // monitoring-scoped share links — skip the analytics dashboard
        // entirely: straight to the coverage report. The flag is computed
        // SERVER-side (/api/config, /api/portal) so it's always present.
        const cfg0 = (state.config?.sites || []).find((x) => x.id === r.id);
        const monOnly = state.portalView === 'monitoring' || cfg0?.monitoringOnly === true;
        if (monOnly) {
          if (!live()) return;
          renderMonitoringOnly(el, r.id);
          return;
        }
        const data = await api('/api/site/' + encodeURIComponent(r.id) + '?range=' + encodeURIComponent(state.range));
        if (!live()) return; // a newer load took over — don't paint stale data
        renderSite(el, data);
      }
    } catch (err) {
      if (!live()) return;
      if (err.message !== 'unauthorised') el.innerHTML = `<div class="err">Couldn't load data: ${esc(err.message)}</div>`;
    }
  }

  function renderOverview(el) {
    const { data, sparks } = state.pageData;
    const t = data.totals, p = data.previous;
    const { key, dir } = state.sort;
    const filter = (state.siteFilter || '').toLowerCase();
    const sites = [...data.sites]
      .filter((s) => !filter || s.name.toLowerCase().includes(filter) || s.id.toLowerCase().includes(filter))
      .sort((a, b) => dir * ((a[key] ?? 0) > (b[key] ?? 0) ? 1 : -1));
    const th = (k, label) => `<th data-sort="${k}">${label} ${state.sort.key === k ? (dir < 0 ? '↓' : '↑') : ''}</th>`;
    el.innerHTML = `
      <div class="grid" style="gap:16px">
        <div id="alerts-card"></div>
        <div class="grid tiles">
          ${tile('Sessions — all sites', fmtInt(t.sessions), delta(t.sessions, p.sessions))}
          ${tile('Users', fmtInt(t.totalUsers), delta(t.totalUsers, p.totalUsers))}
          ${tile('Page views', fmtInt(t.screenPageViews), delta(t.screenPageViews, p.screenPageViews))}
          ${tile('Conversions', fmtInt(t.conversions), delta(t.conversions, p.conversions))}
          ${tile('Engagement rate', fmtPct(t.engagementRate), delta(t.engagementRate, p.engagementRate))}
        </div>
        <div class="card" id="insights-card"></div>
        <div class="grid two">
          <div class="card">
            <div class="card-head">
              <h3>Combined traffic — all ${data.sites.length} sites</h3>
              <div class="metric-toggle" id="trend-main-toggle"></div>
            </div>
            <div id="trend-main"></div>
          </div>
          <div class="card">
            <h3>Traffic channels — portfolio</h3>
            ${barList(data.channels, 'sessions')}
            <div class="note" style="margin-top:8px">Sessions by default channel group, ${esc(data.range.startDate)} → ${esc(data.range.endDate)}</div>
          </div>
        </div>
        <div class="card">
          <div class="card-head">
            <h3>All websites</h3>
            <input class="card-search" id="sites-search" placeholder="Search sites…" value="${esc(state.siteFilter || '')}" />
          </div>
          <div style="overflow-x:auto">
            <table class="data">
              <thead><tr>
                ${th('name', 'Site')}<th>Trend</th>${th('sessions', 'Sessions')}${th('previousSessions', 'Prev. period')}
                ${th('totalUsers', 'Users')}${th('conversions', 'Conv.')}${th('engagementRate', 'Eng. rate')}
              </tr></thead>
              <tbody>
                ${sites.map((s) => {
                  const d = delta(s.sessions, s.previousSessions);
                  const dHtml = d == null ? '' :
                    `<span class="delta ${d > 0.001 ? 'up' : d < -0.001 ? 'down' : 'flat'}">${d > 0 ? '↑' : d < 0 ? '↓' : '·'}${Math.abs(d * 100).toFixed(0)}%</span>`;
                  return `<tr class="clickable" data-site="${esc(s.id)}">
                    <td class="site-name">${esc(s.name)}</td>
                    <td>${sparkline(sparks[s.id] || [])}</td>
                    <td class="num">${fmtInt(s.sessions)}</td>
                    <td class="num">${fmtInt(s.previousSessions)} ${dHtml}</td>
                    <td class="num">${fmtInt(s.totalUsers)}</td>
                    <td class="num">${fmtInt(s.conversions)}</td>
                    <td class="num">${fmtPct(s.engagementRate)}</td>
                  </tr>`;
                }).join('')}
              </tbody>
            </table>
          </div>
        </div>
      </div>`;
    initMetricChart('trend-main', data.trend, [], 260, (day) => renderDayModal('all', day));
    const searchEl = document.getElementById('sites-search');
    if (searchEl) {
      searchEl.addEventListener('input', () => {
        state.siteFilter = searchEl.value;
        const pos = searchEl.selectionStart;
        renderOverview(el);
        const again = document.getElementById('sites-search');
        again.focus(); again.setSelectionRange(pos, pos);
      });
    }
    renderInsights('all');
    renderAlerts();
    el.querySelectorAll('[data-sort]').forEach((h) =>
      h.addEventListener('click', () => {
        const k = h.dataset.sort;
        if (state.sort.key === k) state.sort.dir = -state.sort.dir;
        else state.sort = { key: k, dir: -1 };
        renderOverview(el);
      })
    );
    el.querySelectorAll('[data-site]').forEach((row) =>
      row.addEventListener('click', () => { location.hash = '#/site/' + row.dataset.site; })
    );
  }

  // ── Analytics market picker — INSIDE the analytics section ─────────────
  // Newsroom model: a MARKET (country) can carry one or more LANGUAGES
  // (locales). The picker groups locales under their market: ticking the
  // market row selects every language in it; individual languages sit
  // underneath. Rebuilt fresh per site render — options can never leak from
  // one client to the next.
  async function renderGaMarketBar(siteId) {
    const slot = document.getElementById('ga-market-bar');
    if (!slot) return;
    let d;
    try {
      d = await api(`/api/site/${encodeURIComponent(siteId)}/markets?range=${encodeURIComponent(state.range)}`);
    } catch { slot.innerHTML = ''; return; }
    if (route().id !== siteId) return; // user already moved on
    const rows = d.options || [];
    if (rows.length < 2) { slot.innerHTML = ''; return; } // single-market site — nothing to pick
    const mode = d.mode || 'country';
    const sel = String(state.gaMarket[siteId] || '').split(',').filter(Boolean);
    const lbl = !sel.length ? 'All markets'
      : (state.gaMarketLabels[siteId] && sel.length <= 2 ? state.gaMarketLabels[siteId] : `${sel.length} markets`);
    slot.innerHTML = `
      <div class="card" style="padding:10px 16px">
        <div class="card-head" style="margin:0">
          <h3 style="font-size:13px">Analytics — GA4 · platform · email${sel.length ? ` <span class="crumb">🌍 ${esc(lbl)}</span>` : ''}</h3>
          <span style="position:relative">
            <button class="mini-btn" id="ga-market-btn" title="Filter the ANALYTICS section (GA4, platform, email) by market/language — pick a market (all its languages), individual languages, or All.">🌍 ${esc(lbl)} ▾</button>
            <div class="mkt-pop" id="ga-market-pop" hidden></div>
          </span>
        </div>
      </div>`;
    const gmBtn = slot.querySelector('#ga-market-btn');
    const gmPop = slot.querySelector('#ga-market-pop');
    gmBtn.addEventListener('click', (e0) => { e0.stopPropagation(); buildPop(); gmPop.hidden = !gmPop.hidden; if (!gmPop.hidden) placePop(gmBtn, gmPop); });
    gmPop.addEventListener('click', (e0) => e0.stopPropagation());
    document.addEventListener('click', () => { gmPop.hidden = true; }, { once: true });
    const buildPop = () => {
      if (gmPop.dataset.loaded) return;
      // Group locales by MARKET (country). A market with several locales gets
      // a market row + one row per language beneath it.
      const byMkt = new Map();
      for (const x of rows) {
        const mk = (mode === 'locale' && x.country) || x.label;
        if (!byMkt.has(mk)) byMkt.set(mk, []);
        byMkt.get(mk).push(x);
      }
      const groupHtml = [...byMkt.entries()].map(([mk, ls]) => {
        if (ls.length === 1) {
          const x = ls[0];
          return `<label class="mkt-opt"><input type="checkbox" class="gm-cb" value="${esc(x.id)}" data-label="${esc(x.label)}" ${sel.includes(String(x.id)) ? 'checked' : ''}/> ${esc(x.label)} <span class="note">${x.sub ? esc(x.sub) + ' · ' : ''}${fmtInt(x.count || 0)}</span>${mode === 'locale' && isAdminUser() ? `<button class="mini-btn gm-rename" data-loc="${esc(x.id)}" data-cur="${esc(x.label)}" style="padding:2px 7px;font-size:11px">✏️</button>` : ''}</label>`;
        }
        const total = ls.reduce((a2, x) => a2 + (x.count || 0), 0);
        return `<label class="mkt-opt"><input type="checkbox" class="gm-mkt-cb" data-ids="${esc(ls.map((x) => x.id).join(','))}" data-label="${esc(mk)}" ${ls.every((x) => sel.includes(String(x.id))) ? 'checked' : ''}/> <b>${esc(mk)}</b> <span class="note">market · ${fmtInt(total)}</span></label>
          ${ls.map((x) => `<label class="mkt-opt" style="padding-left:22px"><input type="checkbox" class="gm-cb" value="${esc(x.id)}" data-label="${esc(x.label)}" ${sel.includes(String(x.id)) ? 'checked' : ''}/> ${esc(x.language || x.label)} <span class="note">${x.sub ? esc(x.sub) + ' · ' : ''}${fmtInt(x.count || 0)}</span>${mode === 'locale' && isAdminUser() ? `<button class="mini-btn gm-rename" data-loc="${esc(x.id)}" data-cur="${esc(x.label)}" style="padding:2px 7px;font-size:11px">✏️</button>` : ''}</label>`).join('')}`;
      }).join('');
      gmPop.innerHTML = `
        <label class="mkt-opt"><input type="checkbox" id="gm-all" ${!sel.length ? 'checked' : ''}/> <b>All markets${mode === 'locale' ? ' (all languages)' : ''}</b></label>
        <input class="card-search" id="gm-search" placeholder="Search markets & languages…" style="width:100%;margin:6px 0 2px" />
        <div style="max-height:250px;overflow:auto;border-top:1px solid var(--border);margin-top:4px;padding-top:4px">${groupHtml}</div>
        <div class="note" style="margin:8px 0 6px">${mode === 'locale'
          ? 'Markets come from the platform\'s own locale column — a market can have several languages; releases, media &amp; models filter exactly and GA4 follows via reader country + browser language.'
          : 'GA4 graphs filter exactly by country. Platform &amp; email data follow when market-tagged — otherwise matched by campaign title, with a note on the card.'}</div>
        <button class="mini-btn" id="gm-apply" style="width:100%">Apply</button>`;
      gmPop.dataset.loaded = '1';
      const all = gmPop.querySelector('#gm-all');
      const syncAll = () => { if (![...gmPop.querySelectorAll('.gm-cb')].some((x) => x.checked)) all.checked = true; };
      all.addEventListener('change', () => { if (all.checked) gmPop.querySelectorAll('.gm-cb, .gm-mkt-cb').forEach((c) => (c.checked = false)); });
      gmPop.querySelectorAll('.gm-cb').forEach((c) => c.addEventListener('change', () => { if (c.checked) all.checked = false; syncAll(); }));
      gmPop.querySelectorAll('.gm-mkt-cb').forEach((mc) => mc.addEventListener('change', () => {
        const ids = new Set(String(mc.dataset.ids || '').split(',').filter(Boolean));
        gmPop.querySelectorAll('.gm-cb').forEach((c) => { if (ids.has(c.value)) c.checked = mc.checked; });
        if (mc.checked) all.checked = false;
        syncAll();
      }));
      const srch = gmPop.querySelector('#gm-search');
      if (srch) srch.addEventListener('input', () => {
        const q2 = srch.value.toLowerCase().trim();
        gmPop.querySelectorAll('label.mkt-opt').forEach((row) => {
          if (row.querySelector('#gm-all')) return;
          row.style.display = !q2 || row.textContent.toLowerCase().includes(q2) ? '' : 'none';
        });
      });
      gmPop.querySelector('#gm-apply').addEventListener('click', () => {
        const boxes = [...gmPop.querySelectorAll('.gm-cb')].filter((x) => x.checked);
        state.gaMarket[siteId] = boxes.map((x) => x.value).join(',');
        state.gaMarketMode[siteId] = mode;
        state.gaMarketLabels[siteId] = boxes.map((x) => x.dataset.label).join(' + ');
        refresh();
      });
      gmPop.querySelectorAll('.gm-rename').forEach((btn) => btn.addEventListener('click', async (ev) => {
        ev.preventDefault(); ev.stopPropagation();
        const cur = btn.dataset.cur;
        const name = prompt(`Rename this market (locale ${btn.dataset.loc}) — e.g. "Mexico":`, cur);
        if (!name || name.trim() === '' || name === cur) return;
        try {
          await api(`/api/site/${encodeURIComponent(siteId)}/market-name`, {
            method: 'POST', body: JSON.stringify({ locale: btn.dataset.loc, name: name.trim() }),
          });
          renderGaMarketBar(siteId); // rebuild with the new name
        } catch (err) { alert('Could not save the name: ' + err.message); }
      }));
    };
  }

  // Monitoring-only view: no analytics tiles or GA cards — the coverage
  // report IS the page. Used for monitoring-only clients and the
  // monitoring-scoped client share link.
  // ── Client CI — per-site colours, font & logo (set in Clients admin) ───
  // Applied whenever a site with a CI is open: the accent recolours every
  // chart (--series-1), the primary restyles the chrome/buttons, and the
  // font swaps in via Google Fonts. Cleared on portfolio/admin pages.
  function applyClientCi(ci) {
    const ciKey = JSON.stringify(ci || null);
    if (state._ciKey === ciKey) return;
    state._ciKey = ciKey;
    const rs = document.documentElement.style;
    ['--series-1', '--series-2', '--accent', '--accent-strong', '--btn-bg', '--btn-fg', '--app-font'].forEach((v) => rs.removeProperty(v));
    document.getElementById('ci-font-link')?.remove();
    const okHex = (h) => /^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(h || '');
    const lum = (h) => {
      const x = h.length === 4 ? h.slice(1).split('').map((c) => c + c).join('') : h.slice(1);
      const n = parseInt(x, 16);
      return 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
    };
    const mixHex = (hex, f) => {
      const x = hex.length === 4 ? hex.slice(1).split('').map((c) => c + c).join('') : hex.slice(1);
      const n = parseInt(x, 16);
      const ch = (v) => Math.max(0, Math.min(255, Math.round(f < 0 ? v * (1 + f) : v + (255 - v) * f)));
      return '#' + [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => ch(v).toString(16).padStart(2, '0')).join('');
    };
    if (okHex(ci?.accent)) {
      rs.setProperty('--series-1', ci.accent);
      // Second data series (lines over bars, comparison series) follows the
      // brand too — a dark shade of the same accent.
      rs.setProperty('--series-2', mixHex(ci.accent, -0.45));
    }
    if (okHex(ci?.primary)) {
      rs.setProperty('--accent', ci.primary);
      rs.setProperty('--accent-strong', ci.primary);
      rs.setProperty('--btn-bg', ci.primary);
      rs.setProperty('--btn-fg', lum(ci.primary) > 150 ? '#131312' : '#ffffff');
    }
    const fam = String(ci?.font || '').replace(/[^\w \-]/g, '').trim();
    if (fam) {
      const l = document.createElement('link');
      l.id = 'ci-font-link'; l.rel = 'stylesheet';
      l.href = 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(fam).replace(/%20/g, '+') + ':wght@400;450;500;600;700;800&display=swap';
      document.head.appendChild(l);
      rs.setProperty('--app-font', `'${fam}'`);
    }
  }

  // Logo + client name strip shown at the top of a branded client's pages.
  function ciBrandHeader(siteId) {
    const s = (state.config?.sites || []).find((x) => x.id === siteId);
    if (!s?.ci?.logo) return '';
    return `<div style="display:flex;align-items:center;gap:12px;padding:2px 2px 0">
      <img src="${esc(s.ci.logo)}" alt="" style="height:38px;max-width:200px;object-fit:contain"/>
      <div style="font-size:15px;font-weight:700">${esc(s.name)}</div>
    </div>`;
  }

  function renderMonitoringOnly(el, siteId) {
    el.innerHTML = `
      <div class="grid" style="gap:16px">
        ${ciBrandHeader(siteId)}
        <div id="monitoring-cards" style="display:contents"></div>
      </div>`;
    renderMonitoringCards(siteId);
  }

  function renderSite(el, data) {
    const t = data.totals, p = data.previous;
    el.innerHTML = `
      <div class="grid" style="gap:16px">
        ${ciBrandHeader(route().id)}
        <div id="ga-market-bar" style="display:contents"></div>
        <div class="grid tiles">
          ${tile('Sessions', fmtInt(t.sessions), delta(t.sessions, p.sessions))}
          ${tile('Users', fmtInt(t.totalUsers), delta(t.totalUsers, p.totalUsers))}
          ${tile('New users', fmtInt(t.newUsers), delta(t.newUsers, p.newUsers))}
          ${tile('Page views', fmtInt(t.screenPageViews), delta(t.screenPageViews, p.screenPageViews))}
          ${tile('Conversions', fmtInt(t.conversions), delta(t.conversions, p.conversions))}
          ${tile('Avg. session', fmtDur(t.avgSessionDuration), delta(t.avgSessionDuration, p.avgSessionDuration))}
          ${tile('Engagement rate', fmtPct(t.engagementRate), delta(t.engagementRate, p.engagementRate))}
        </div>
        <div class="card" id="pr-snap"></div>
        <div class="card" id="cadence-card" style="display:none"></div>
        <div class="card" id="insights-card"></div>
        <div class="card">
          <div class="card-head">
            <h3>Daily traffic</h3>
            <div class="metric-toggle" id="trend-site-toggle"></div>
          </div>
          <div id="trend-site"></div>
        </div>
        <div class="card" id="peaks-card" style="display:none"></div>
        <div class="card" id="recon-card" style="display:none"></div>
        <div class="grid two">
          <div class="card" id="pages-card"></div>
          <div class="grid" style="gap:14px">
            <div class="card" id="channels-card"></div>
            <div class="card" id="devices-card"><h3>Devices</h3>${deviceBars(data.devices)}<div class="note" style="margin-top:6px">Click a device for its sources, channels &amp; pages</div></div>
            <div class="card" id="nvr-card"></div>
          </div>
        </div>
        <div class="card" id="heatmap-card" style="display:none"></div>
        <div class="grid two">
          <div class="card" id="flows-card" style="display:none"></div>
          <div class="card" id="outbound-card" style="display:none"></div>
        </div>
        <div class="grid two">
          <div class="card" id="dow-card"></div>
          <div class="card" id="searches-card" style="display:none"></div>
        </div>
        <div class="grid two">
          <div class="card" id="chmix-card" style="display:none"></div>
          <div class="card" id="miscpages-card" style="display:none"></div>
          <div class="card" id="model-interest-card" style="display:none"></div>
          <div class="card" id="email-intel-card" style="display:none"></div>
        </div>
        <div class="card" id="countries-card"></div>
        <div class="card" id="uploads-card" style="display:none"></div>
        <div class="card" id="quiet-card" style="display:none"></div>
        <div class="card" id="email-card" style="display:none"></div>
        <div class="card" id="releases-card"></div>
        <div class="card" id="radar-card"></div>
        <div id="legacy-cards" style="display:contents"></div>
        <div id="monitoring-cards" style="display:contents"></div>
      </div>`;
    const siteId = route().id;
    state.sitePages = data.pages;
    state.siteTotals = { cur: data.totals, prev: data.previous };
    state.siteTrend = data.trend;
    state.siteMarkers = { email: [], peaks: [] };
    initMetricChart('trend-site', data.trend, [], 250, (day) => renderDayModal(siteId, day));
    renderGaMarketBar(siteId);
    renderPagesCard(siteId, data.pages, null, data.pages);
    renderChannelsCard(siteId, data.channels, null);
    renderCountriesCard(siteId, data.countries, null);
    renderPrSnapshot(data);
    renderInsights(siteId);
    renderReleasesCard(siteId);
    renderRadarCard(siteId);
    renderEmailCard(siteId, data.trend);
    renderPeaksCard(siteId);
    renderReconCard(siteId);
    renderHeatmapCard(siteId);
    renderFlowsCard(siteId);
    renderOutboundCard(siteId);
    renderNewVsReturning(data);
    renderDayOfWeek(data.trend);
    renderSearchesCard(siteId);
    renderChannelMixCard(siteId);
    renderMiscPagesCard(siteId);
    renderModelInterestCard(siteId);
    renderEmailIntelCard(siteId);
    renderUploadsCard(siteId);
    renderQuietCard(siteId);
    el.querySelectorAll('[data-dev]').forEach((row) =>
      row.addEventListener('click', () => renderDeviceModal(siteId, row.dataset.dev))
    );
    renderLegacyCards(siteId);
    renderMonitoringCards(siteId);
  }

  // ── legacy reports-DB cards (platform data: titles, downloads, assets…) ─
  function renderLegacyCards(siteId) {
    const slot = document.getElementById('legacy-cards');
    const cadEl = document.getElementById('cadence-card');
    const siteCfg = (state.config?.sites || []).find((x) => x.id === siteId);
    if (!slot || !siteCfg?.kind) { if (slot) slot.innerHTML = ''; if (cadEl) cadEl.style.display = 'none'; return; }
    slot.innerHTML = '<div class="card"><h3>Platform data</h3><div class="note">Loading from the reports database…</div></div>';
    const company = state.companySel?.[siteId] || '';
    api(`/api/site/${encodeURIComponent(siteId)}/legacy?range=${encodeURIComponent(state.range)}${company ? `&company=${encodeURIComponent(company)}` : ''}`)
      .then((d) => {
        if (!d.enabled) { slot.innerHTML = ''; if (cadEl) cadEl.style.display = 'none'; return; }
        const isNP = d.kind === 'newspress';
        let html = '';
        // Analytics market filter status on PLATFORM data — honest either way.
        if (d.platformMarkets) {
          html += `<div class="note" style="margin:2px 0 8px;color:var(--warn-text)">${d.platformMarkets.applied
            ? `🌍 Platform releases filtered by market via the database's <b>${esc(d.platformMarkets.column)}</b> column.`
            : `🌍 This platform database isn't market-tagged — platform cards show ALL markets (GA4 graphs above are filtered exactly).`}${d.emailMarkets ? d.emailMarkets.applied
            ? ` Platform emails: ${fmtInt(d.emailMarkets.matched)} of ${fmtInt(d.emailMarkets.total)} campaigns matched by title.`
            : ' Platform emails: no titles matched — showing all.' : ''}</div>`;
        }
        if (d.errors?.length) {
          html += `<div class="card" style="border-color:var(--warn-text)"><h3>Platform data — partial</h3>
            <div class="note" style="color:var(--warn-text)">Some queries failed on this database (schema differences are normal across sites):</div>
            ${d.errors.map((e2) => `<div class="note">⚠ ${esc(e2)}</div>`).join('')}</div>`;
        }
        // Company picker (Newspress markets)
        if (isNP && d.companies?.length) {
          html += `<div class="card"><div class="card-head"><h3>Client filter — Newspress market</h3>
            <select class="rep-select" id="company-sel">
              <option value="">All clients (${d.companies.length})</option>
              ${d.companies.map((c) => `<option value="${c.id}" ${String(c.id) === String(company) ? 'selected' : ''}>${esc(c.name)}</option>`).join('')}
            </select></div>
            <div class="note">Filters the platform cards below to one client's releases, email, assets and banners.</div></div>`;
        }
        // Content volume vs audience — rendered up top, next to the PR snapshot
        if (cadEl && d.cadence && d.cadence.prevTotal != null) {
          const cad = d.cadence;
          const cd = cad.prevTotal ? (cad.total - cad.prevTotal) / cad.prevTotal : null;
          const dd = cad.distPrev ? (cad.distTotal - cad.distPrev) / cad.distPrev : null;
          const st = state.siteTotals;
          const sd = st?.prev?.sessions ? (st.cur.sessions - st.prev.sessions) / st.prev.sessions : null;
          const perRel = cad.total ? Math.round((st?.cur?.sessions || 0) / cad.total) : null;
          let verdict = '';
          if (cd != null && sd != null) {
            if (Math.abs(cd) >= 0.15 && Math.sign(cd) === Math.sign(sd)) verdict = '→ Traffic moved with publishing volume — this looks content-led.';
            else if (Math.abs(cd) < 0.1 && Math.abs(sd) >= 0.1) verdict = '→ Publishing steady but audience moved — distribution or interest-led, not volume.';
            else if (Math.sign(cd) !== Math.sign(sd) && Math.abs(cd) >= 0.15) verdict = cd < 0 ? '→ Fewer releases, more audience — efficiency per story is up.' : '→ More releases, less audience — volume isn\'t converting.';
            else verdict = '→ No strong volume effect either way this period.';
          }
          const pill = (v) => v == null ? '' : ` <span class="delta ${v > 0.001 ? 'up' : v < -0.001 ? 'down' : 'flat'}">${v > 0 ? '↑' : v < 0 ? '↓' : '·'}${Math.abs(v * 100).toFixed(0)}%</span>`;
          cadEl.innerHTML = `<h3>Content volume vs audience${company ? ' · filtered client' : ''}</h3>
            <div class="facts-row">
              <span class="fact-pill">📰 <b>${fmtInt(cad.total)}</b> releases published${pill(cd)}</span>
              ${cad.distTotal != null ? `<span class="fact-pill">📤 <b>${fmtInt(cad.distTotal)}</b> email distributions${pill(dd)}</span>` : ''}
              ${sd != null ? `<span class="fact-pill">👥 Sessions <span class="delta ${sd > 0.001 ? 'up' : sd < -0.001 ? 'down' : 'flat'}">${sd > 0 ? '↑' : '↓'}${Math.abs(sd * 100).toFixed(1)}%</span></span>` : ''}
              ${perRel ? `<span class="fact-pill">⚡ <b>${fmtInt(perRel)}</b> sessions per release</span>` : ''}
            </div>
            <div id="cadence-chart" style="margin-top:10px"></div>
            ${verdict ? `<div class="note" style="margin-top:6px"><b style="color:var(--text-primary)">${verdict}</b></div>` : ''}
            ${!cad.total && d.freshness?.lastPublished ? `<div class="note" style="margin-top:6px;color:var(--warn-text)">⚠ 0 releases found in this period — the newest release in the connected database is dated <b>${esc(d.freshness.lastPublished)}</b> (${fmtInt(d.freshness.totalReleases)} releases total). If the live site has newer releases, this database copy is behind and needs a re-sync.</div>` : ''}`;
          cadEl.style.display = '';
          // one combined chart: sessions line + published/distributed bars per day
          const byDate = {};
          (cad.days || []).forEach((x) => { byDate[String(x.date).slice(0, 10)] = x; });
          const trend = state.siteTrend || [];
          const chartDays = trend.length
            ? trend.map((t) => ({ date: t.date, sessions: t.sessions || 0, published: byDate[t.date]?.published || 0, distributed: byDate[t.date]?.distributed || 0 }))
            : (cad.days || []).map((x) => ({ date: String(x.date).slice(0, 10), sessions: 0, published: x.published || 0, distributed: x.distributed || 0 }));
          cadenceChart(document.getElementById('cadence-chart'), chartDays, (day) => {
            const rels = (d.releases || []).filter((r) => String(r.published || '').slice(0, 10) === day);
            const ems = (d.emails || []).filter((e2) => String(e2.sentDate || '').slice(0, 10) === day);
            const dd = chartDays.find((x) => x.date === day);
            const mb = openModal(`
              <h3 style="margin-bottom:8px">📅 ${esc(day)} — content &amp; distribution</h3>
              <div class="facts-row" style="margin-bottom:10px">
                <span class="fact-pill">📰 <b>${dd ? dd.published : 0}</b> released</span>
                <span class="fact-pill">📤 <b>${dd ? dd.distributed : 0}</b> distributed</span>
                ${dd?.sessions ? `<span class="fact-pill">👥 <b>${fmtInt(dd.sessions)}</b> sessions</span>` : ''}
              </div>
              ${rels.length ? `<h3>Released that day</h3><table class="data"><tbody>${rels.map((r) => `
                <tr class="clickable" data-cdrel="${esc(String(r.id))}"><td class="site-name">${esc(r.title || '#' + r.id)}</td>
                <td class="num">${fmtInt(r.viewed)} views</td></tr>`).join('')}</tbody></table>` : '<div class="note">No top-25 releases published this day (smaller stories may not be listed).</div>'}
              ${ems.length ? `<h3 style="margin-top:12px">Emailed that day</h3><table class="data"><tbody>${ems.map((e2) => `
                <tr><td class="site-name">${esc(e2.title || e2.campaignId)}</td><td class="num">${fmtInt(e2.recipients)} sent</td></tr>`).join('')}</tbody></table>` : ''}
              <div class="note" style="margin-top:10px">Tip: click the traffic chart above on the same day to see the GA4 side.</div>`);
            mb.querySelectorAll('[data-cdrel]').forEach((r2) =>
              r2.addEventListener('click', () => renderLegacyReleaseModal(siteId, r2.dataset.cdrel))
            );
          });
        } else if (cadEl) {
          cadEl.style.display = 'none';
        }
        // Releases with native stats + titles — date-ordered by default (what
        // the content team sends), with a views toggle and search.
        if (d.releases?.length) {
          html += `<div class="card" id="plat-rel-card"></div>`;
        }
        // Media assets + platform side-cards
        if (d.media?.length || d.models?.length || d.banners?.length || d.categories?.length || d.audience || d.audienceTypes?.length) {
          html += `<div class="grid two">
            ${d.media?.length ? `<div class="card" id="media-assets-card"></div>` : ''}
            ${d.documents?.length ? `<div class="card"><div class="card-head"><h3>Documents — spec sheets, PDFs & press kits ${d.docsScope === 'period' ? '<span class="crumb" style="font-size:11.5px">this period</span>' : ''}</h3>${listControls('documents')}</div>
              <div style="overflow-x:auto"><table class="data">
              <thead><tr><th>Document</th><th class="num">Downloads</th></tr></thead>
              <tbody>${d.documents.map((m) => `
                <tr class="clickable" data-mid="${esc(String(m.id))}">
                  <td class="site-name">📄 ${esc(String(m.name || '').trim() || (m.releaseTitle ? String(m.releaseTitle).slice(0, 60) : 'Document #' + m.id))}</td>
                  <td class="num">${fmtInt(m.downloaded)}</td>
                </tr>`).join('')}</tbody></table></div>
              <div class="note" style="margin-top:6px">"Which spec kits are journalists downloading?" — click one to see the releases it belongs to</div>
            </div>` : ''}
            <div class="grid" style="gap:14px">
              ${d.models?.length ? `<div class="card"><div class="card-head"><h3>Vehicle models — most viewed ${d.modelsScope === 'period' ? '<span class="crumb" style="font-size:11.5px">this period</span>' : '<span class="note" style="font-size:11.5px">all-time</span>'}</h3>${listControls('models')}</div>
                ${d.models.slice(0, 8).map((m) => `
                  <div class="bar-row clickable" data-taxon="model" data-tid="${esc(String(m.id))}" data-tname="${esc(m.name)}"><div class="bar-label">${esc(m.name)}</div>
                  <div class="bar-track"><div class="bar-fill" style="width:${(100 * m.viewed) / Math.max(1, d.models[0].viewed)}%"></div></div>
                  <div class="bar-value">${fmtInt(m.viewed)}</div></div>`).join('')}
                <div class="note" style="margin-top:6px">${d.modelsScope === 'period' ? 'Model-page views within the selected dates & market — changes with the filters above.' : 'Lifetime model-page views (this database has no dated model events).'} Click a model for its releases.</div></div>` : ''}
              ${d.banners?.length ? `<div class="card"><div class="card-head"><h3>Banners — clicks</h3>${listControls('banners')}</div>
                ${d.banners.map((b) => `
                  <div class="bar-row"><div class="bar-label" title="${esc(b.title)}">${esc(b.title)}</div>
                  <div class="bar-track"><div class="bar-fill" style="width:${(100 * b.clicked) / Math.max(1, ...d.banners.map((b2) => b2.clicked || 0))}%"></div></div>
                  <div class="bar-value">${fmtInt(b.clicked)}</div></div>`).join('')}</div>` : ''}
              ${d.categories?.length ? `<div class="card"><div class="card-head"><h3>Categories — releases this period</h3>${listControls('categories')}</div>
                ${d.categories.slice(0, 8).map((c) => `
                  <div class="bar-row clickable" data-taxon="category" data-tid="${esc(String(c.id))}" data-tname="${esc(c.name)}"><div class="bar-label">${esc(c.name)}</div>
                  <div class="bar-track"><div class="bar-fill" style="width:${(100 * c.releases) / Math.max(1, d.categories[0].releases)}%"></div></div>
                  <div class="bar-value">${fmtInt(c.releases)}</div></div>`).join('')}
                <div class="note" style="margin-top:6px">Click a category to see its releases</div></div>` : ''}
              ${d.audience ? `<div class="card"><h3>Registered audience</h3>
                <div class="facts-row">
                  <span class="fact-pill">🗞 <b>${fmtInt(d.audience.journalists)}</b> journalists</span>
                  <span class="fact-pill">👥 <b>${fmtInt(d.audience.consumers)}</b> consumers</span>
                  <span class="fact-pill">🏢 <b>${fmtInt(d.audience.mediaClients)}</b> media clients</span>
                </div></div>` : ''}
              ${!d.audience && d.audienceTypes?.length ? `<div class="card"><h3>Registered audience</h3>
                <div class="facts-row">
                  ${d.audienceTypes.map((t) => `<span class="fact-pill">👥 <b>${fmtInt(t.n)}</b> ${esc(t.type)}</span>`).join('')}
                </div></div>` : ''}
            </div>
          </div>`;
        }
        if (d.topJournalists?.length || d.topPublications?.length) {
          html += `<div class="grid two">
            ${d.topJournalists?.length ? `<div class="card"><div class="card-head"><h3>Top journalists — most active</h3>${listControls('journalists')}</div>
              ${d.topJournalists.map((j) => `
                <div class="bar-row clickable" data-jid="${esc(String(j.userId || ''))}" data-jemail="${esc(j.email || '')}" data-jname="${esc((j.name || '').trim() || j.email || 'Unknown')}"><div class="bar-label" title="${esc(j.email || '')}">${esc((j.name || '').trim() || j.email || 'Unknown')}</div>
                <div class="bar-track"><div class="bar-fill" style="width:${(100 * j.events) / Math.max(1, d.topJournalists[0].events)}%"></div></div>
                <div class="bar-value">${fmtInt(j.events)}</div></div>`).join('')}
              <div class="note" style="margin-top:6px">Click a journalist for their full activity — releases, downloads, email events</div></div>` : ''}
            ${d.topPublications?.length ? `<div class="card"><div class="card-head"><h3>Top publications</h3>${listControls('publications')}</div>
              ${d.topPublications.map((pb) => `
                <div class="bar-row clickable" data-pubid="${esc(String(pb.id || ''))}" data-pubname="${esc(pb.name)}"><div class="bar-label">${esc(pb.name)}</div>
                <div class="bar-track"><div class="bar-fill" style="width:${(100 * pb.events) / Math.max(1, d.topPublications[0].events)}%"></div></div>
                <div class="bar-value">${fmtInt(pb.events)}</div></div>`).join('')}
              <div class="note" style="margin-top:6px">Click a publication for its journalists and what they're reading</div></div>` : ''}
          </div>`;
        }
        // DB email campaigns
        if (d.emails?.length) {
          html += `<div class="card"><h3>Email campaigns — from reports DB <span class="prov-badge">db</span></h3>
            <div style="overflow-x:auto"><table class="data">
            <thead><tr><th>Campaign</th><th>Sent</th><th class="num">Recipients</th><th class="num">Opens</th><th class="num">Clicks</th><th class="num">Unsubs</th></tr></thead>
            <tbody>${d.emails.slice(0, 10).map((e2) => `
              <tr><td class="site-name">${esc(e2.title || e2.campaignId)}</td><td>${esc(String(e2.sentDate || '—'))}</td>
              <td class="num">${fmtInt(e2.recipients)}</td>
              <td class="num">${fmtInt(e2.opened)} <span class="note">(${(() => { const b = e2.delivered || e2.recipients || 0; return b ? Math.min(100, Math.round((100 * e2.opened) / b)) : 0; })()}%)</span></td>
              <td class="num">${fmtInt(e2.clicked)}</td><td class="num">${fmtInt(e2.unsubscribed)}</td></tr>`).join('')}</tbody></table></div>
            <div class="note" style="margin-top:6px">Sourced from your own database — unified across Brevo & SparkPost, linked to releases</div></div>`;
        }
        slot.innerHTML = html;
        slot.querySelector('#company-sel')?.addEventListener('change', (e2) => {
          state.companySel = state.companySel || {};
          state.companySel[siteId] = e2.target.value;
          renderLegacyCards(siteId);
        });
        slot.querySelectorAll('[data-rid]').forEach((row) =>
          row.addEventListener('click', () => renderLegacyReleaseModal(siteId, row.dataset.rid))
        );
        slot.querySelectorAll('[data-taxon]').forEach((row) =>
          row.addEventListener('click', () => openTaxonModal(siteId, row.dataset.taxon, row.dataset.tid, row.dataset.tname))
        );
        if (d.releases?.length) renderPlatformReleases(siteId, d, isNP);
        slot.querySelectorAll('[data-jid]').forEach((row) =>
          row.addEventListener('click', () => renderJournalistModal(siteId, row.dataset.jid, row.dataset.jemail, row.dataset.jname))
        );
        slot.querySelectorAll('[data-pubid]').forEach((row) =>
          row.addEventListener('click', () => renderPublicationModal(siteId, row.dataset.pubid, row.dataset.pubname))
        );
        slot.querySelectorAll('[data-mid]').forEach((row) =>
          row.addEventListener('click', () => renderMediaModal(siteId, row.dataset.mid))
        );
        // Re-render the media card LAST (it manages its own handlers, so the
        // blanket data-mid bind above must not double-bind its rows).
        state.legacyMediaScope = d.mediaScope || null;
        if (d.media?.length) renderMediaAssetsCard(siteId, d.media);
        // "View all" + search-all controls on every platform card
        const listOpts = company ? { journalists: { company }, media: { company }, documents: { company }, banners: { company } } : {};
        bindListControls(slot, siteId, listOpts);
      })
      .catch(() => { slot.innerHTML = ''; });
  }

  // Releases for one category / model — shared by cards and the View-all popup.
  async function openTaxonModal(siteId, taxon, tid, tname) {
    const mb = openModal(`<h3>${esc(tname)}</h3><div class="note">Loading releases…</div>`);
    try {
      const tr = await api(`/api/site/${encodeURIComponent(siteId)}/legacy-taxon?taxon=${encodeURIComponent(taxon)}&id=${encodeURIComponent(tid)}&range=${encodeURIComponent(state.range)}`);
      const isFallback = tr.rows.length && tr.rows[0]._recentFallback;
      mb.innerHTML = `<h3>${esc(tname)} — releases</h3>
        ${isFallback ? `<div class="note" style="margin-bottom:8px;color:var(--warn-text)">No releases were published in the selected period${state.gaMarket?.[siteId] ? ' for this market' : ''} — showing the most recent releases instead. The model page itself was still being viewed in the period.</div>` : ''}
        ${tr.rows.length ? `<table class="data"><thead><tr><th>Release</th><th>Published</th><th class="num">Views</th></tr></thead>
        <tbody>${tr.rows.map((r) => `<tr class="clickable" data-rid2="${esc(String(r.id))}">
          <td class="site-name">${esc(r.title || '#' + r.id)}</td><td>${esc(String(r.published || '—').slice(0, 10))}</td>
          <td class="num">${fmtInt(r.viewed)}</td></tr>`).join('')}</tbody></table>` : '<div class="note">No releases in this period.</div>'}`;
      mb.querySelectorAll('[data-rid2]').forEach((r2) =>
        r2.addEventListener('click', () => renderLegacyReleaseModal(siteId, r2.dataset.rid2))
      );
    } catch (err) { mb.innerHTML = `<div class="err">${esc(err.message)}</div>`; }
  }

  // ── "View all" popups: full list + search + pagination for every graph ──
  const MTYPE_ICON = { image: '🖼', video: '🎬', document: '📄', misc: '📦' };
  const LIST_SPECS = {
    journalists: {
      title: 'Journalists — full ranking', what: 'journalists', unit: 'events',
      text: (r) => `${(r.name || '').trim() || r.email || 'Unknown'}${r.publications && String(r.publications).length > 1 ? ' · ' + String(r.publications).split(',')[0].trim() : ''}`,
      val: (r) => r.events,
      click: (siteId, r) => renderJournalistModal(siteId, String(r.id || r.userId || ''), r.email || '', (r.name || '').trim() || r.email || 'Unknown'),
      note: 'ranked by release views + downloads in this period',
    },
    publications: {
      title: 'Publications — full ranking', what: 'publications', unit: 'events',
      text: (r) => r.name, val: (r) => r.events,
      click: (siteId, r) => renderPublicationModal(siteId, String(r.id || ''), r.name),
    },
    media: {
      title: 'Media assets — all', what: 'media', unit: 'downloads',
      text: (r) => `${MTYPE_ICON[r.type] || ''} ${r.name}${r.releaseTitle ? ' · ' + r.releaseTitle : ''}`, val: (r) => Math.max(r.downloaded || 0, r.type === 'video' ? r.viewed || 0 : 0),
      click: (siteId, r) => renderMediaModal(siteId, String(r.id)),
    },
    videos: {
      title: 'Videos — all', what: 'videos', unit: 'views / downloads',
      text: (r) => `🎬 ${r.name}${r.releaseTitle ? ' · ' + r.releaseTitle : ''}`, val: (r) => Math.max(r.viewed || 0, r.downloaded || 0),
      click: (siteId, r) => renderMediaModal(siteId, String(r.id)),
    },
    documents: {
      title: 'Documents — all', what: 'documents', unit: 'downloads',
      text: (r) => `📄 ${r.name}${r.releaseTitle ? ' · ' + r.releaseTitle : ''}`, val: (r) => r.downloaded,
      click: (siteId, r) => renderMediaModal(siteId, String(r.id)),
    },
    models: {
      title: 'Vehicle models — all', what: 'models', unit: 'views',
      text: (r) => r.name, val: (r) => r.viewed,
      click: (siteId, r) => openTaxonModal(siteId, 'model', String(r.id), r.name),
    },
    categories: {
      title: 'Categories — all', what: 'categories', unit: 'releases',
      text: (r) => r.name, val: (r) => r.releases,
      click: (siteId, r) => openTaxonModal(siteId, 'category', String(r.id), r.name),
    },
    banners: {
      title: 'Banners — all', what: 'banners', unit: 'clicks',
      text: (r) => r.title, val: (r) => r.clicked,
    },
    countries: {
      title: 'Countries — all', what: 'countries', unit: 'sessions',
      text: (r) => r.dimensionValue, val: (r) => r.sessions,
    },
    channels: {
      title: 'Traffic channels — all', what: 'channels', unit: 'sessions',
      text: (r) => r.dimensionValue, val: (r) => r.sessions,
    },
    searches: {
      title: 'On-site searches — all', what: 'searches', unit: 'searches',
      text: (r) => `🔍 ${r.term}`, val: (r) => r.searches,
    },
    // Media-monitoring (myNewspress) full rankings — served by /monitoring-list
    'mon-publications': {
      title: 'Coverage publications — full ranking', what: 'publications', unit: 'articles', endpoint: 'monitoring',
      text: (r) => `${r.name}${r.mediaType ? ` (${r.mediaType})` : ''}`, val: (r) => r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'publication', r.name),
      note: 'every publication that covered the brand in this period',
    },
    'mon-journalists': {
      title: 'Coverage journalists — full ranking', what: 'journalists', unit: 'articles', endpoint: 'monitoring',
      text: (r) => `✍️ ${r.name}`, val: (r) => r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'journalist', r.name),
      note: 'every byline behind this period\'s coverage',
    },
    'mon-models': {
      title: 'Model coverage — full ranking', what: 'models', unit: 'articles', endpoint: 'monitoring',
      text: (r) => `🚗 ${r.name}`, val: (r) => r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'model', r.name),
    },
    'mon-topics': {
      title: 'Coverage categories — full ranking', what: 'topics', unit: 'articles', endpoint: 'monitoring',
      text: (r) => r.name, val: (r) => r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'topic', r.name),
    },
    'mon-regions': {
      title: 'Region split — full list', what: 'regions', unit: 'articles', endpoint: 'monitoring',
      text: (r) => `🌍 ${r.name}`, val: (r) => r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'region', r.name),
    },
    'mon-marketSplit': {
      title: 'Market split — full list', what: 'marketSplit', unit: 'articles', endpoint: 'monitoring',
      text: (r) => `🌍 ${r.name}`, val: (r) => r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'market', r.name),
    },
    'mon-articles': {
      title: 'Coverage articles — all, by reach', what: 'articles', unit: 'reach', endpoint: 'monitoring',
      text: (r) => `${r.title}${r.publication ? ' · ' + r.publication : ''}${r.date ? ' · ' + String(r.date).slice(0, 10) : ''}`,
      val: (r) => r.reach,
      click: (siteId, r) => { if (r.url) window.open(r.url, '_blank'); },
      note: 'every tracked article in the period — click to open the piece',
    },
    'mon-brands': {
      title: 'Share of voice — all brands', unit: 'reach',
      text: (r) => `🏁 ${r.name}`, val: (r) => r.reach || r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'brand', r.name),
    },
    'mon-mediaTypes': {
      title: 'Media types — all', unit: 'articles',
      text: (r) => r.name, val: (r) => r.articles,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'mediaType', r.name),
    },
    'mon-sentimentByRegion': {
      title: 'Sentiment by region — all regions', unit: 'articles',
      text: (r) => `${r.region}: ${r.detail}`, val: (r) => r.tot,
      click: (siteId, r) => renderMonArticlesModal(siteId, 'region', r.region),
    },
    'mon-releases': {
      title: 'Release → coverage — all releases', unit: 'coverage reach',
      text: (r) => `${r.title}${r.articles ? ` · ${r.articles} articles` : ' · no tracked coverage'}`,
      val: (r) => r.reach || 0,
    },
  };

  async function openListModal(siteId, specKey, initialQuery = '', opts = {}) {
    const spec = LIST_SPECS[specKey];
    if (!spec) return;
    const body = openModal(`<h3>${esc(spec.title)}</h3><div class="note">Loading the full list…</div>`);
    let rows = [];
    try {
      if (opts.rows) {
        rows = opts.rows; // already on screen — no fetch needed
      } else {
      const extra = opts.company ? `&company=${encodeURIComponent(opts.company)}` : '';
      const mkt = spec.endpoint === 'monitoring' && state.monMarket?.[siteId] ? `&market=${encodeURIComponent(state.monMarket[siteId])}` : '';
      const path = spec.endpoint === 'monitoring' ? 'monitoring-list' : 'list';
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/${path}?what=${encodeURIComponent(spec.what)}&range=${encodeURIComponent(state.range)}&limit=500${extra}${mkt}`);
      rows = d.rows || [];
      }
    } catch (err) {
      body.innerHTML = `<h3>${esc(spec.title)}</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
      return;
    }
    let shown = 25;
    let query = initialQuery || '';
    const render = () => {
      const ql = query.trim().toLowerCase();
      const filtered = ql ? rows.filter((r) => spec.text(r).toLowerCase().includes(ql)) : rows;
      const page = filtered.slice(0, shown);
      const max = Math.max(1, ...page.map((r) => spec.val(r) || 0));
      body.innerHTML = `
        <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
          <h3 style="margin:0;flex:1">${esc(spec.title)} <span class="note" style="font-weight:400">${filtered.length}${rows.length >= 500 ? ' (top 500 loaded)' : ''}</span></h3>
          <button class="mini-btn" id="lm-csv" title="Download this list (matching your search) as a CSV for Excel / Google Sheets">⬇ CSV</button>
        </div>
        <input class="card-search" id="lm-q" placeholder="Search the whole list…" value="${esc(query)}" style="width:100%;margin-bottom:10px" />
        ${page.length ? page.map((r, pi) => `
          <div class="bar-row wide ${spec.click ? 'clickable' : ''}" data-li="${pi}">
            <div class="bar-label" title="${esc(spec.text(r))}"><span class="note" style="display:inline-block;min-width:30px">${filtered.indexOf(r) + 1}.</span>${esc(spec.text(r))}</div>
            <div class="bar-track"><div class="bar-fill" style="width:${(100 * (spec.val(r) || 0)) / max}%"></div></div>
            <div class="bar-value">${fmtInt(spec.val(r))}</div>
          </div>`).join('') : '<div class="note">Nothing matches that search.</div>'}
        ${filtered.length > shown ? `<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
          ${[10, 50, 100].filter((n) => n < filtered.length - shown + 100).map((n) => `<button class="mini-btn" data-more="${n}">+ Next ${n}</button>`).join('')}
          <button class="mini-btn" data-more="all">Show all ${filtered.length}</button>
        </div>` : ''}
        <div class="note" style="margin-top:10px">Showing ${Math.min(shown, filtered.length)} of ${filtered.length} · ${esc(spec.unit)}${spec.note ? ' — ' + esc(spec.note) : ''}${spec.click ? ' · click a row to drill in' : ''}</div>`;
      const qEl = body.querySelector('#lm-q');
      qEl.addEventListener('input', () => {
        query = qEl.value; shown = 25;
        const pos = qEl.selectionStart;
        render();
        const again = body.querySelector('#lm-q');
        again.focus(); again.setSelectionRange(pos, pos);
      });
      body.querySelectorAll('[data-more]').forEach((b) =>
        b.addEventListener('click', () => { shown = b.dataset.more === 'all' ? filtered.length : shown + Number(b.dataset.more); render(); })
      );
      if (spec.click) body.querySelectorAll('[data-li]').forEach((rw) =>
        rw.addEventListener('click', () => spec.click(siteId, page[Number(rw.dataset.li)]))
      );
      // Export the FULL filtered list (not just the visible page) as CSV —
      // the raw columns, so it drops straight into Sheets / pivot tables.
      body.querySelector('#lm-csv')?.addEventListener('click', () => {
        const cols = [...new Set(filtered.flatMap((r) => Object.keys(r)))];
        const cell = (v) => { const s2 = v == null ? '' : String(v); return /[",\n]/.test(s2) ? '"' + s2.replace(/"/g, '""') + '"' : s2; };
        const csv = [cols.join(','), ...filtered.map((r) => cols.map((c) => cell(r[c])).join(','))].join('\n');
        saveBlob(new Blob(['﻿' + csv], { type: 'text/csv;charset=utf-8' }),
          `${slugify(spec.title)}-${new Date().toISOString().slice(0, 10)}.csv`);
      });
    };
    render();
    const qEl0 = body.querySelector('#lm-q');
    if (qEl0) { qEl0.focus(); qEl0.setSelectionRange(query.length, query.length); }
  }

  // Card-head controls: a search box + View-all button that open the popup.
  function listControls(specKey) {
    return `<span style="display:flex;gap:6px;align-items:center">
      <input class="card-search list-q" data-spec="${specKey}" placeholder="Search all…" style="width:110px" />
      <button class="mini-btn list-all" data-spec="${specKey}" title="Open the full list with search & pagination">⤢ View all</button>
    </span>`;
  }
  function bindListControls(root, siteId, optsBySpec = {}) {
    root.querySelectorAll('.list-all:not([data-bound])').forEach((b) => {
      b.dataset.bound = '1';
      b.addEventListener('click', () => openListModal(siteId, b.dataset.spec, '', optsBySpec[b.dataset.spec] || {}));
    });
    root.querySelectorAll('.list-q:not([data-bound])').forEach((inp) => {
      inp.dataset.bound = '1';
      inp.addEventListener('input', () => {
        const v = inp.value;
        inp.value = '';
        openListModal(siteId, inp.dataset.spec, v, optsBySpec[inp.dataset.spec] || {});
      });
    });
  }

  // Media assets card with an Images / Videos / Documents split (Hyundai-style
  // "top image downloads / top video downloads / top document downloads").
  function renderMediaAssetsCard(siteId, media) {
    const el = document.getElementById('media-assets-card');
    if (!el) return;
    state.mediaTypeSel = state.mediaTypeSel || {};
    const sel = state.mediaTypeSel[siteId] || 'all';
    const types = [...new Set(media.map((m) => m.type).filter(Boolean))];
    const poolRows = sel === 'all' ? [...media] : media.filter((m) => m.type === sel);
    const ICON = { image: '🖼', video: '🎬', document: '📄', misc: '📦' };
    // Videos are viewed far more than downloaded — rank/annotate by whichever
    // interaction the asset actually gets, so the Videos tab is never "no data".
    const useViews = sel === 'video' && poolRows.every((m) => !(m.downloaded > 0)) && poolRows.some((m) => m.viewed > 0);
    const metric = (m) => useViews ? (m.viewed || 0) : Math.max(m.downloaded || 0, sel === 'video' ? m.viewed || 0 : 0);
    // Sort by the DISPLAYED metric first, then cut — never show 8 below 42.
    const rows = poolRows.sort((a, b) => metric(b) - metric(a)).slice(0, sel === 'all' ? 10 : 15);
    const max = Math.max(1, ...rows.map(metric));
    const label = { all: 'Media assets', image: 'Image downloads', video: useViews ? 'Video views' : 'Video downloads', document: 'Document downloads', misc: 'Other assets' };
    el.innerHTML = `
      <div class="card-head">
        <h3>${label[sel] || 'Media assets'} — most downloaded</h3>
        <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
          ${types.length > 1 ? `<div class="metric-toggle">
            <button data-mt="all" class="${sel === 'all' ? 'active' : ''}">All</button>
            ${types.map((t) => `<button data-mt="${esc(t)}" class="${sel === t ? 'active' : ''}">${esc(t.charAt(0).toUpperCase() + t.slice(1) + 's')}</button>`).join('')}
          </div>` : ''}
          ${listControls('media')}
        </span>
      </div>
      ${rows.length ? rows.map((m) => `
        <div class="bar-row clickable" data-mid="${esc(String(m.id))}"><div class="bar-label" title="${esc(m.name)}${m.releaseTitle ? ` — from “${esc(m.releaseTitle)}”` : ''} — ${fmtInt(m.viewed)} views · ${fmtInt(m.downloaded)} downloads">${ICON[m.type] || ''} ${esc(m.name)}${m.releaseTitle ? ` <span class="note">· ${esc(String(m.releaseTitle).slice(0, 44))}${String(m.releaseTitle).length > 44 ? '…' : ''}</span>` : ''}</div>
        <div class="bar-track"><div class="bar-fill" style="width:${(100 * metric(m)) / max}%"></div></div>
        <div class="bar-value">${fmtInt(metric(m))}</div></div>`).join('') : '<div class="note">No assets of this type in the period.</div>'}
      <div class="note" style="margin-top:6px">${useViews ? 'Views' : 'Downloads'} by asset${state.legacyMediaScope === 'period' ? ' <b>within the selected dates &amp; market</b> — changes with the filters above' : ' — internal codes (EX0112…) are shown with the release they belong to'}. Click one for its full stats &amp; releases</div>`;
    el.querySelectorAll('[data-mt]').forEach((b) =>
      b.addEventListener('click', () => { state.mediaTypeSel[siteId] = b.dataset.mt; renderMediaAssetsCard(siteId, media); })
    );
    el.querySelectorAll('[data-mid]').forEach((row) =>
      row.addEventListener('click', () => renderMediaModal(siteId, row.dataset.mid))
    );
    const company = state.companySel?.[siteId] || '';
    bindListControls(el, siteId, company ? { media: { company } } : {});
    // Thumbnails for the visible image assets (when the media table stores a
    // usable file path/url) — no more manual thumbnail pulling for reports.
    const imgRows = rows.filter((m) => m.type === 'image').slice(0, 12);
    if (imgRows.length) {
      api(`/api/site/${encodeURIComponent(siteId)}/media-paths?ids=${imgRows.map((m) => m.id).join(',')}`)
        .then((d) => {
          const base = siteUrlFor(siteId) || '';
          el.querySelectorAll('[data-mid]').forEach((row) => {
            const p = d.paths?.[row.dataset.mid];
            if (!p) return;
            const src = /^https?:\/\//.test(p) ? p : base ? base + (p.startsWith('/') ? '' : '/') + p : null;
            if (!src) return;
            const img = document.createElement('img');
            img.className = 'mthumb';
            img.loading = 'lazy';
            img.src = src;
            img.onerror = () => img.remove();
            row.querySelector('.bar-label')?.prepend(img);
            row.classList.add('has-thumb');
          });
        }).catch(() => { /* thumbnails are a bonus */ });
    }
  }

  // Platform releases card: BY DATE by default (the content team's view),
  // toggle to by-views, with search. Rows open the Release 360° dossier.
  async function renderPlatformReleases(siteId, d, isNP) {
    const el = document.getElementById('plat-rel-card');
    if (!el) return;
    state.platSort = state.platSort || {};
    const sort = state.platSort[siteId] || 'date';
    let rows;
    if (sort === 'date') {
      state.platDateRows = state.platDateRows || {};
      const cacheKey = siteId + '|' + state.range;
      if (!state.platDateRows[cacheKey]) {
        try {
          const r2 = await api(`/api/site/${encodeURIComponent(siteId)}/legacy-releases?range=${encodeURIComponent(state.range)}&limit=150`);
          state.platDateRows[cacheKey] = r2.rows || [];
        } catch { state.platDateRows[cacheKey] = []; }
      }
      rows = state.platDateRows[cacheKey];
      if (!rows.length) rows = d.releases; // fall back to the top-views list
    } else {
      rows = d.releases;
    }
    const table = (list) => `
      <div style="overflow-x:auto"><table class="data">
      <thead><tr><th>Date</th><th>Release</th>${isNP ? '<th>Client</th>' : ''}<th class="num">Views</th>${isNP ? '<th class="num">Downloads</th><th class="num">Basket</th>' : ''}</tr></thead>
      <tbody>${list.map((r) => `
        <tr class="clickable" data-prid="${esc(String(r.id))}">
          <td style="white-space:nowrap">${esc(String(r.published || '—').slice(0, 10))}</td>
          <td class="site-name">${esc(r.title || ('#' + r.id))}<div class="note" style="font-weight:400">#${esc(String(r.id))}</div></td>
          ${isNP ? `<td class="note">${esc(r.companyName || '—')}</td>` : ''}
          <td class="num">${fmtInt(r.viewed)}</td>
          ${isNP ? `<td class="num">${fmtInt(r.downloaded)}</td><td class="num">${fmtInt(r.basket)}</td>` : ''}
        </tr>`).join('')}</tbody></table></div>`;
    el.innerHTML = `
      <div class="card-head">
        <h3>Releases — platform stats</h3>
        <div style="display:flex;gap:8px;align-items:center">
          <div class="metric-toggle">
            <button data-psort="date" class="${sort === 'date' ? 'active' : ''}">By date</button>
            <button data-psort="views" class="${sort === 'views' ? 'active' : ''}">By views</button>
          </div>
          <input class="card-search" id="plat-rel-search" placeholder="Search releases…" />
        </div>
      </div>
      <div id="plat-rel-list">${table(rows.slice(0, sort === 'date' ? 50 : 12))}</div>
      <div class="note" style="margin-top:6px">${sort === 'date' ? `Every release this period in date order (${rows.length})` : 'Top releases by platform views'} — click one for its 360° dossier (GA4 + email joined)</div>`;
    const bind = () => el.querySelectorAll('[data-prid]').forEach((row) =>
      row.addEventListener('click', () => renderLegacyReleaseModal(siteId, row.dataset.prid)));
    bind();
    el.querySelectorAll('[data-psort]').forEach((b) =>
      b.addEventListener('click', () => { state.platSort[siteId] = b.dataset.psort; renderPlatformReleases(siteId, d, isNP); }));
    el.querySelector('#plat-rel-search').addEventListener('input', (e2) => {
      const qq = e2.target.value.toLowerCase();
      const filtered = rows.filter((r) => (r.title || '').toLowerCase().includes(qq) || String(r.id).includes(qq) || (r.companyName || '').toLowerCase().includes(qq));
      el.querySelector('#plat-rel-list').innerHTML = filtered.length ? table(filtered.slice(0, 60)) : '<div class="note">No releases match.</div>';
      bind();
    });
  }

  // ── media-monitoring cards (optional second DB: coverage articles) ────
  // Multi-segment donut as an SVG string (used across the myNewspress section).
  function donutStr(segs, size = 118, center = '', centerSub = '') {
    const tot = segs.reduce((a, s) => a + (s.value || 0), 0);
    if (!tot) return '';
    const R = 44, C = 2 * Math.PI * R;
    let acc = 0;
    const rings = segs.map((s) => {
      const len = (C * s.value) / tot;
      const el2 = `<circle cx="60" cy="60" r="${R}" fill="none" stroke="${s.color}" stroke-width="17"
        stroke-dasharray="${len.toFixed(1)} ${C.toFixed(1)}" transform="rotate(${((360 * acc) / tot - 90).toFixed(1)} 60 60)"/>`;
      acc += s.value;
      return el2;
    }).join('');
    return `<svg viewBox="0 0 120 120" width="${size}" height="${size}" style="flex-shrink:0">${rings}
      ${center ? `<text x="60" y="${centerSub ? 57 : 64}" text-anchor="middle" font-size="17" font-weight="700" fill="var(--text-primary)">${esc(center)}</text>` : ''}
      ${centerSub ? `<text x="60" y="73" text-anchor="middle" font-size="9.5" fill="var(--text-muted)">${esc(centerSub)}</text>` : ''}</svg>`;
  }

  const SENT_COLOR = { positive: 'var(--series-3)', 'very positive': 'var(--series-3)', neutral: 'var(--text-muted)', negative: 'var(--bad-text)', 'very negative': 'var(--bad-text)', unclassified: 'var(--text-muted)' };
  const sentColor = (n) => SENT_COLOR[String(n).toLowerCase()] || 'var(--series-2)';

  // Articles behind a clicked monitoring segment.
  async function renderMonArticlesModal(siteId, kind, value) {
    const label = kind === 'day' ? 'Articles on ' + value : `${value} — articles`;
    const body = openModal(`<h3>📰 ${esc(label)}</h3><div class="note">Pulling the articles behind this…</div>`);
    try {
      const mk = state.monMarket?.[siteId] || '';
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/monitoring-articles?range=${encodeURIComponent(state.range)}&kind=${encodeURIComponent(kind)}&value=${encodeURIComponent(value)}${mk ? `&market=${encodeURIComponent(mk)}` : ''}`);
      const rows = d.articles || [];
      const totR = rows.reduce((a, x) => a + (Number(x.reach) || 0), 0);
      body.innerHTML = `
        <h3 style="margin-bottom:8px">📰 ${esc(label)}</h3>
        <div class="facts-row" style="margin-bottom:10px">
          <span class="fact-pill">🗞 <b>${fmtInt(rows.length)}</b> articles${rows.length >= 100 ? ' (top 100)' : ''}</span>
          ${totR ? `<span class="fact-pill">📡 <b>${kfmt(totR)}</b> combined reach</span>` : ''}
        </div>
        ${rows.length ? `<div style="overflow-x:auto"><table class="data">
        <thead><tr><th>Article</th><th>Publication</th><th>Market</th><th>Date</th><th>Sentiment</th><th class="num">Reach</th></tr></thead>
        <tbody>${rows.map((a2) => `<tr>
          <td class="site-name">${esc(a2.title)}${a2.url ? ` <a class="ext" style="visibility:visible" href="${esc(a2.url)}" target="_blank" rel="noopener">↗</a>` : ''}</td>
          <td class="note">${esc(a2.publication || '—')}</td>
          <td class="note">${esc(a2.market || '—')}</td>
          <td class="note" style="white-space:nowrap">${esc(String(a2.date || '').slice(0, 10))}</td>
          <td>${a2.sentiment ? `<span class="delta ${String(a2.sentiment).includes('pos') ? 'up' : String(a2.sentiment).includes('neg') ? 'down' : 'flat'}">${esc(a2.sentiment)}</span>` : '—'}</td>
          <td class="num">${a2.reach ? kfmt(a2.reach) : '—'}</td>
        </tr>`).join('')}</tbody></table></div>` : '<div class="note">No articles matched this segment in the period.</div>'}`;
    } catch (err) {
      body.innerHTML = `<h3>📰 ${esc(label)}</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // Analyst-narrative bullets appended under a monitoring card.
  function monNarrHtml(arr) {
    return `<div class="mon-narr">${arr.map((b) => `<div class="mon-narr-b">${esc(b)}</div>`).join('')}</div>`;
  }
  function applyMonNarrative(slot, sections) {
    state.monNarrLive = sections;
    const map = {
      overview: [...(sections.volumeReach || []), ...(sections.overtime || [])],
      regions: sections.regions, topics: sections.topics, models: sections.models,
      mediaTypes: sections.mediaTypes, publications: sections.publications,
      sentiment: sections.sentiment, sentimentByRegion: sections.sentimentByRegion,
      shareOfVoice: sections.shareOfVoice, releases: sections.releases,
      journalists: sections.journalists, articles: sections.articles,
    };
    for (const [sec, arr] of Object.entries(map)) {
      if (!arr?.length) continue;
      const cardEl = slot.querySelector(`[data-mon-sec="${sec}"]`);
      if (!cardEl) continue;
      cardEl.querySelector('.mon-narr')?.remove();
      cardEl.insertAdjacentHTML('beforeend', monNarrHtml(arr));
    }
    const out = document.getElementById('mon-ai-out');
    if (out && sections.keyInsight?.length) {
      out.innerHTML = `<div class="msg assistant ai-summary"><b>KEY INSIGHT</b><br>${sections.keyInsight.map((b) => `<div style="margin:4px 0">${esc(b)}</div>`).join('')}</div>`;
    }
  }

  // ── myNewspress media monitoring — the full coverage report section ────
  function renderMonitoringCards(siteId) {
    const slot = document.getElementById('monitoring-cards');
    const siteCfg = (state.config?.sites || []).find((x) => x.id === siteId);
    if (!slot || !siteCfg?.monitoring) { if (slot) slot.innerHTML = ''; return; }
    slot.innerHTML = '<div class="card"><h3>Media monitoring — myNewspress</h3><div class="note">Loading coverage data…</div></div>';
    state.monMarket = state.monMarket || {};
    const selMarket = state.monMarket[siteId] || '';
    api(`/api/site/${encodeURIComponent(siteId)}/monitoring?range=${encodeURIComponent(state.range)}${selMarket ? `&market=${encodeURIComponent(selMarket)}` : ''}`)
      .then((d) => {
        if (!d.enabled) { slot.innerHTML = ''; return; }
        if (d.error) {
          slot.innerHTML = `<div class="card" style="border-color:var(--warn-text)"><h3>Media monitoring — myNewspress</h3>
            <div class="note" style="color:var(--warn-text)">⚠ ${esc(d.error)}</div></div>`;
          return;
        }
        const t = d.totals || {};
        // Real DB connected but nothing in this window → explain and offer a
        // one-click jump to where the coverage actually is.
        if (!t.articles && d.freshness) {
          const f2 = d.freshness;
          slot.innerHTML = `<div class="card"><div class="card-head"><h3>Media monitoring — myNewspress</h3>
            <button class="mini-btn" id="mon-dl" style="display:none"></button></div>
            ${f2.totalArticles > 0 ? `
              <div class="note">The connected myNewspress database holds <b>${fmtInt(f2.totalArticles)}</b> articles, but <b>none dated in the selected period</b>${f2.lastArticle ? ` — the newest article is dated <b>${esc(f2.lastArticle)}</b>` : ''}. All the coverage graphs appear as soon as the period contains articles.</div>
              ${f2.lastArticle ? `<button class="mini-btn" id="mon-jump" style="margin-top:10px">📅 Jump to the latest coverage month (${esc(f2.lastArticle.slice(0, 7))})</button>` : ''}
              <div class="note" style="margin-top:8px">If the live myNewspress has newer articles than ${esc(f2.lastArticle || 'this')}, the connected copy is behind and needs a re-sync.</div>`
            : `<div class="note">Connected to the myNewspress database, but its <b>articles table is empty</b> — check this is the right tenant schema for this client (Sites → Test monitoring DB shows the article count).</div>`}
          </div>`;
          if (selMarket) {
            slot.querySelector('.card').insertAdjacentHTML('beforeend',
              `<div class="note" style="margin-top:8px">A market filter is active — it may be hiding coverage. <button class="mini-btn" id="mon-mkt-reset">Reset to All markets (HQ)</button></div>`);
            slot.querySelector('#mon-mkt-reset')?.addEventListener('click', () => { state.monMarket[siteId] = ''; renderMonitoringCards(siteId); });
          }
          slot.querySelector('#mon-jump')?.addEventListener('click', () => {
            const [y, m] = f2.lastArticle.split('-').map(Number);
            const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
            state.range = `${f2.lastArticle.slice(0, 7)}-01..${f2.lastArticle.slice(0, 7)}-${String(last).padStart(2, '0')}`;
            refresh();
          });
          return;
        }
        const pill = (cur, prevV) => {
          const dd = prevV ? (cur - prevV) / prevV : null;
          return dd == null ? '' : ` <span class="delta ${dd > 0.001 ? 'up' : dd < -0.001 ? 'down' : 'flat'}">${dd > 0 ? '↑' : dd < 0 ? '↓' : '·'}${Math.abs(dd * 100).toFixed(0)}%</span>`;
        };
        const bl = (rows, key = 'reach') => barList((rows || []).map((r) => ({ dimensionValue: r.name, v: r[key] || r.articles || 0 })), 'v', 'dimensionValue', key === 'reach' ? kfmt : fmtInt);
        const totS = (d.sentiment || []).reduce((a, x) => a + (x.articles || 0), 0);
        const posShare = totS ? Math.round((100 * (d.sentiment || []).filter((x) => String(x.name).toLowerCase().includes('positive')).reduce((a, x) => a + x.articles, 0)) / totS) : null;
        const mtOnline = (d.mediaTypes || []).find((x) => String(x.name).toLowerCase().includes('online'));
        const mtPrint = (d.mediaTypes || []).find((x) => String(x.name).toLowerCase().includes('print'));

        // 1 — KEY METRICS + volume & reach over time
        // Merged markets carry comma-joined ids ('1,27') — a market counts as
        // selected when ALL of its ids are in the selection.
        const selIds0 = String(selMarket || '').split(',').filter(Boolean);
        const mktIsSel = (m2) => String(m2.id).split(',').filter(Boolean).every((x) => selIds0.includes(x));
        const selMkts = (d.markets || []).filter(mktIsSel);
        // If the selection IS exactly one region's markets, name the region.
        const regionOfSel0 = (() => {
          if (!selIds0.length || !d.markets?.length) return null;
          const set = new Set(selIds0);
          const groups = {};
          for (const m2 of d.markets) {
            if (!m2.region) continue;
            (groups[m2.region] = groups[m2.region] || []).push(...String(m2.id).split(',').filter(Boolean));
          }
          for (const [rg, ids] of Object.entries(groups)) {
            if (ids.length === set.size && ids.every((x) => set.has(x))) return rg;
          }
          return null;
        })();
        // Short, layout-safe header label: region name, or ≤2 names + "+N".
        const headLabel = !selIds0.length ? ''
          : regionOfSel0 ? regionOfSel0
          : selMkts.length <= 2 ? selMkts.map((m2) => m2.name).join(' + ')
          : `${selMkts.slice(0, 2).map((m2) => m2.name).join(' + ')} +${selMkts.length - 2} more`;
        let html = `<div class="mon-divider"><span>📡 Media Monitoring<span class="mon-divider-sub">press coverage · myNewspress</span></span></div>
        <div class="card" data-mon-sec="overview"><div class="card-head"><h3>Media monitoring — myNewspress${headLabel ? ` — <span class="crumb">${esc(headLabel)}</span>` : ''}</h3>
          <span style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
          ${d.markets?.length ? (() => {
            const selIds = selIds0;
            const mLabel = !selIds.length ? 'All markets (HQ)'
              : regionOfSel0 ? regionOfSel0
              : selMkts.length === 1 ? selMkts[0].name
              : `${selMkts.length || selIds.length} markets`;
            // Group markets under their LISTED regions — a region row selects
            // all of its markets in one click, exactly how the reports are
            // split. Markets with NO region are not offered here at all —
            // "All markets (HQ)" still covers everything.
            const byRegion = {};
            for (const m2 of d.markets) { if (m2.region) (byRegion[m2.region] = byRegion[m2.region] || []).push(m2); }
            const regionNames = Object.keys(byRegion).sort((a2, b2) => a2.localeCompare(b2));
            return `<span style="position:relative">
            <button class="mini-btn" id="mon-market-btn" title="Filter the whole myNewspress section by REGION or market — pick a region (all its markets), one or several markets, or All (HQ)">🌍 ${esc(mLabel)} ▾</button>
            <div class="mkt-pop left" id="mon-market-pop" hidden>
              <label class="mkt-opt"><input type="checkbox" id="mkt-all" ${!selIds.length ? 'checked' : ''}/> <b>All markets (HQ)</b></label>
              <input class="card-search" id="mkt-search" placeholder="Search regions & markets…" style="width:100%;margin:6px 0 2px" />
              <div style="max-height:min(46vh, 280px);overflow:auto;border-top:1px solid var(--border);margin-top:4px;padding-top:4px">
                ${regionNames.map((rg) => `
                  <label class="mkt-opt mkt-region" data-rg="${esc(rg)}"><input type="checkbox" class="mkt-rg-cb" data-ids="${esc(byRegion[rg].map((m2) => m2.id).join('|'))}" ${byRegion[rg].length && byRegion[rg].every(mktIsSel) ? 'checked' : ''}/> <b>${esc(rg)}</b> <span class="note">region · ${fmtInt(byRegion[rg].reduce((a2, m2) => a2 + (m2.articles || 0), 0))}</span></label>
                  ${byRegion[rg].map((m2) => `<label class="mkt-opt mkt-under" data-rg="${esc(rg)}" style="padding-left:22px"><input type="checkbox" class="mkt-cb" value="${esc(String(m2.id))}" ${mktIsSel(m2) ? 'checked' : ''}/> ${esc(m2.name)} <span class="note">${fmtInt(m2.articles || 0)}</span></label>`).join('')}`).join('')}
              </div>
              <button class="mini-btn" id="mkt-apply" style="margin-top:8px;width:100%">Apply</button>
            </div>
          </span>`; })() : ''}
          <button class="mini-btn" id="mon-dl" title="Generate the full Media Monitoring &amp; Insight report for this period — KEY INSIGHT, MoM table, donuts, sentiment by region, release→coverage, publications online/print, SOV, highlight cards — as a downloadable file">⬇ Download the myNewspress report</button>
          </span>
        </div>
          <div class="facts-row">
            <span class="fact-pill">📰 <b>${fmtInt(t.articles)}</b> articles${pill(t.articles, t.prevArticles)}</span>
            ${t.reach ? `<span class="fact-pill">📡 <b>${kfmt(t.reach)}</b> total reach${pill(t.reach, t.prevReach)}</span>` : ''}
            ${t.ave ? `<span class="fact-pill">💷 <b>£${kfmt(t.ave)}</b> AVE${pill(t.ave, t.prevAve)}</span>` : ''}
            ${posShare != null ? `<span class="fact-pill">😊 <b>${posShare}%</b> positive</span>` : ''}
            ${mtOnline ? `<span class="fact-pill">🖥 <b>${fmtInt(mtOnline.articles)}</b> online${mtPrint ? ` · 🗞 <b>${fmtInt(mtPrint.articles)}</b> print` : ''}</span>` : ''}
            ${t.shares ? `<span class="fact-pill">🔁 <b>${fmtInt(t.shares)}</b> social shares</span>` : ''}
            ${t.publications ? `<span class="fact-pill">🗞 <b>${fmtInt(t.publications)}</b> publications</span>` : ''}
          </div>
          <div style="overflow-x:auto;margin-top:10px"><table class="data" style="max-width:560px">
            <thead><tr><th>KPIs</th><th class="num">Previous</th><th class="num">This period</th><th class="num">MoM</th></tr></thead>
            <tbody>
              ${[['Volume', t.prevArticles, t.articles, fmtInt], ['Reach', t.prevReach, t.reach, kfmt], t.ave != null ? ['AVE', t.prevAve, t.ave, (v) => '£' + kfmt(v)] : null]
                .filter(Boolean).map(([l2, pv2, cv2, f2]) => {
                  const dd2 = pv2 ? Math.round(((cv2 - pv2) / pv2) * 100) : null;
                  return `<tr><td class="site-name">${l2}</td><td class="num">${f2(pv2 || 0)}</td><td class="num"><b>${f2(cv2 || 0)}</b></td>
                    <td class="num"><span class="delta ${dd2 > 0 ? 'up' : dd2 < 0 ? 'down' : 'flat'}">${dd2 == null ? '—' : (dd2 > 0 ? '+' : '') + dd2 + '%'}</span></td></tr>`;
                }).join('')}
            </tbody></table></div>
          <div id="mon-chart" style="margin-top:10px"></div>
        </div>`;

        // 2 — Intelligence: computed what's-working/what's-not + AI key insight
        if (d.insights?.length) {
          const META = { up: ['▲', 'WIN'], down: ['▼', 'DOWN'], watch: ['⚠', 'WATCH'], note: ['•', 'NOTE'] };
          html += `<div class="card"><h3>myNewspress intelligence — what's working &amp; what's not</h3>
            <div class="insights">${d.insights.map((i) => {
              const [icon, label] = META[i.kind] || META.note;
              return `<div class="insight">
                <span class="ins-ic ${i.kind === 'note' ? 'info' : i.kind}">${icon}<span class="ins-lbl">${label}</span></span>
                <div class="ins-body"><div class="ins-fact">${esc(i.text)}</div>
                ${i.action ? `<div class="ins-sugg">→ ${esc(i.action)}</div>` : ''}</div>
              </div>`;
            }).join('')}</div>
            ${state.config?.chatConfigured ? '<button class="back-link" id="mon-ai" style="margin-top:10px">✦ Write the analyst narrative — KEY INSIGHT + notes under every graph</button><div id="mon-ai-out"></div>' : ''}
          </div>`;
        }

        // 3 — sentiment donut + sentiment by region
        const g1 = [];
        if (d.sentiment?.length) {
          g1.push(`<div class="card" data-mon-sec="sentiment"><h3>Overall sentiment</h3>
            <div style="display:flex;align-items:center;gap:22px;margin-top:6px;flex-wrap:wrap">
              ${donutStr(d.sentiment.map((x) => ({ value: x.articles || 0, color: sentColor(x.name) })), 150, posShare != null ? posShare + '%' : '', 'positive')}
              <div style="flex:1;min-width:230px">${d.sentiment.map((x) => `<div class="sent-leg clickable" data-mdrill="sentiment::${esc(x.name)}" title="Click for the ${esc(x.name)} articles">
                <span class="chip-dot" style="background:${sentColor(x.name)}"></span>
                <span class="sent-name">${esc(x.name)}</span>
                <span class="sent-val">${fmtInt(x.articles)} · ${Math.round((100 * (x.articles || 0)) / Math.max(1, totS))}%${x.reach ? ` · ${kfmt(x.reach)} reach` : ''}</span>
              </div>`).join('')}</div>
            </div></div>`);
        }
        let sbrRows = [];
        if (d.sentimentByRegion?.length) {
          const byReg = {};
          d.sentimentByRegion.forEach((x) => {
            (byReg[x.region] = byReg[x.region] || { region: x.region, segs: {}, tot: 0 });
            // Fold 'very positive'/'very negative' into their base bucket so
            // the three-colour stack counts every article.
            const sk0 = String(x.sentiment).toLowerCase();
            const sk = /pos/.test(sk0) ? 'positive' : /neg/.test(sk0) ? 'negative' : 'neutral';
            byReg[x.region].segs[sk] = (byReg[x.region].segs[sk] || 0) + (x.articles || 0);
            byReg[x.region].tot += x.articles || 0;
          });
          const regs = Object.values(byReg).sort((a, b) => b.tot - a.tot).slice(0, 8);
          sbrRows = Object.values(byReg).sort((a, b) => b.tot - a.tot).map((x) => ({
            region: x.region, tot: x.tot,
            detail: ['positive', 'neutral', 'negative'].map((k) => x.segs[k] ? `${k} ${fmtInt(x.segs[k])}` : null).filter(Boolean).join(' · '),
          }));
          const maxR = Math.max(1, ...regs.map((x) => x.tot));
          g1.push(`<div class="card" data-mon-sec="sentimentByRegion"><div class="card-head"><h3>Sentiment by region</h3>${listControls('mon-sentimentByRegion')}</div>
            ${regs.map((x) => `<div class="bar-row wide big clickable" data-mdrill="region::${esc(x.region)}" title="${esc(x.region)}: ${Object.entries(x.segs).map(([k, v]) => `${k} ${v}`).join(' · ')} — click for the articles">
              <div class="bar-label">${esc(x.region)}</div>
              <div class="bar-track" style="background:transparent"><div style="display:flex;height:17px;border-radius:5px;overflow:hidden;width:${(100 * x.tot) / maxR}%">
                ${['positive', 'neutral', 'negative'].map((k) => x.segs[k] ? `<div style="width:${(100 * x.segs[k]) / x.tot}%;background:${sentColor(k)}"></div>` : '').join('')}
              </div></div>
              <div class="bar-value">${fmtInt(x.tot)}</div>
            </div>`).join('')}
            <div class="note" style="margin-top:6px"><span class="chip-dot" style="background:var(--series-3)"></span> positive · <span class="chip-dot" style="background:var(--text-muted)"></span> neutral · <span class="chip-dot" style="background:var(--bad-text)"></span> negative</div>
          </div>`);
        }
        if (g1.length) html += `<div class="grid two">${g1.join('')}</div>`;

        // 4 — region split + coverage category
        const g2 = [];
        if (d.regions?.length) {
          const totReach = d.regions.reduce((a, x) => a + (x.reach || 0), 0);
          g2.push(`<div class="card" data-mon-sec="regions"><div class="card-head"><h3>Region split — volume &amp; share of reach</h3>${listControls('mon-regions')}</div>
            ${d.regions.map((x) => `<div class="bar-row wide big clickable" data-mdrill="region::${esc(x.name)}" title="${esc(x.name)}: ${fmtInt(x.articles)} articles${x.reach ? ` · ${kfmt(x.reach)} reach` : ''} — click for the articles">
              <div class="bar-label">${esc(x.name)}${totReach ? ` <span class="note">· ${Math.round((100 * (x.reach || 0)) / totReach)}% of reach</span>` : ''}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (x.articles || 0)) / Math.max(1, d.regions[0].articles || 1)}%"></div></div>
              <div class="bar-value">${fmtInt(x.articles)}</div>
            </div>`).join('')}
            ${d.unmappedMarkets?.length ? `<div class="note" style="margin-top:6px">⚠ "Unassigned" holds markets with no region set in myNewspress: ${d.unmappedMarkets.slice(0, 6).map((u) => `${esc(u.market)} (${fmtInt(u.articles)})`).join(', ')}${d.unmappedMarkets.length > 6 ? '…' : ''} — assign them to a region there and they move automatically.</div>` : ''}</div>`);
        }
        // Market split — same chart per MARKET (only when there is >1 market)
        if (d.marketSplit?.length > 1) {
          g2.push(`<div class="card" data-mon-sec="marketSplit"><div class="card-head"><h3>Market split — volume &amp; share of reach</h3>${listControls('mon-marketSplit')}</div>
            ${d.marketSplit.map((x) => `<div class="bar-row wide big clickable" data-mdrill="market::${esc(x.name)}" title="${esc(x.name)}${x.region ? ' (' + esc(x.region) + ')' : ''}: ${fmtInt(x.articles)} articles${x.reach ? ` · ${kfmt(x.reach)} reach` : ''} — click for the articles">
              <div class="bar-label">${esc(x.name)}<span class="note">${x.region ? ` · ${esc(x.region)}` : ''} · ${x.reachShare || 0}% of reach</span></div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (x.articles || 0)) / Math.max(1, d.marketSplit[0].articles || 1)}%"></div></div>
              <div class="bar-value">${fmtInt(x.articles)}</div>
            </div>`).join('')}</div>`);
        }
        if (d.topics?.length) {
          const totT = d.topics.reduce((a, x) => a + (x.articles || 0), 0) || 1;
          g2.push(`<div class="card" data-mon-sec="topics"><div class="card-head"><h3>Coverage category</h3>${listControls('mon-topics')}</div>
            ${d.topics.map((x) => `<div class="bar-row wide clickable" data-mdrill="topic::${esc(x.name)}" title="Click for the ${esc(x.name)} articles">
              <div class="bar-label">${esc(x.name)} <span class="note">· ${Math.round((100 * (x.articles || 0)) / totT)}%</span></div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (x.articles || 0)) / Math.max(1, d.topics[0].articles || 1)}%"></div></div>
              <div class="bar-value">${fmtInt(x.articles)}</div>
            </div>`).join('')}
            <div class="note" style="margin-top:6px">Press release vs product vs corporate vs fleet — where the coverage comes from</div></div>`);
        }
        if (g2.length) html += `<div class="grid two">${g2.join('')}</div>`;

        // 5 — media type donut + models
        const g3 = [];
        if (d.mediaTypes?.length) {
          const MT_COLOR = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)', 'var(--text-muted)'];
          const totM = d.mediaTypes.reduce((a, x) => a + (x.articles || 0), 0) || 1;
          g3.push(`<div class="card" data-mon-sec="mediaTypes"><div class="card-head"><h3>Media type</h3>${listControls('mon-mediaTypes')}</div>
            <div style="display:flex;align-items:center;gap:22px;margin-top:6px;flex-wrap:wrap">
              ${donutStr(d.mediaTypes.map((x, i) => ({ value: x.articles || 0, color: MT_COLOR[i % 4] })))}
              <div style="flex:1;min-width:230px">${d.mediaTypes.map((x, i) => `<div class="sent-leg clickable" data-mdrill="mediaType::${esc(x.name)}" title="Click for the ${esc(x.name)} articles">
                <span class="chip-dot" style="background:${MT_COLOR[i % 4]}"></span>
                <span class="sent-name">${esc(x.name)}</span>
                <span class="sent-val">${fmtInt(x.articles)} · ${Math.round((100 * (x.articles || 0)) / totM)}%</span>
              </div>`).join('')}</div>
            </div></div>`);
        }
        const drillRows = (rows, key, kind2) => {
          const max2 = Math.max(1, ...rows.map((r) => r[key] || 0));
          const f2 = key === 'reach' ? kfmt : fmtInt;
          return rows.map((r) => `
            <div class="bar-row clickable" data-mdrill="${kind2}::${esc(r.name)}" title="${esc(r.name)} — click for the articles behind this">
              <div class="bar-label">${esc(r.name)}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r[key] || 0)) / max2}%"></div></div>
              <div class="bar-value">${f2(r[key] || 0)}</div>
            </div>`).join('');
        };
        if (d.models?.length) g3.push(`<div class="card" data-mon-sec="models"><div class="card-head"><h3>Current model — coverage volume</h3>${listControls('mon-models')}</div>${drillRows(d.models, 'articles', 'model')}<div class="note" style="margin-top:6px">Which models the media are writing about — click one for its articles</div></div>`);
        if (g3.length) html += `<div class="grid two">${g3.join('')}</div>`;

        // 6 — press release engagement vs coverage reach (newspress ↔ coverage)
        const rc = (d.releaseCoverage || []).filter(Boolean);
        if (rc.length) {
          const maxE = Math.max(1, ...rc.map((x) => x.engagements || 0));
          const maxCR = Math.max(1, ...rc.map((x) => x.reach || x.articles || 0));
          html += `<div class="card" data-mon-sec="releases"><div class="card-head"><h3>Press release engagement vs coverage reach</h3>${listControls('mon-releases')}</div>
            <div class="note" style="margin-bottom:8px">Left bar: journalist engagement on the platform · right bar: earned media reach picked up by the press</div>
            ${rc.map((x, i) => `<div class="bar-row wide clickable" data-rcrel="${esc(String(x.releaseId))}" title="${esc(x.title)} — ${fmtInt(x.engagements)} platform engagements · ${fmtInt(x.articles)} articles · ${kfmt(x.reach)} reach">
              <div class="bar-label">${esc(x.title)}</div>
              <div style="display:flex;gap:4px;align-items:center">
                <div class="bar-track" style="flex:1"><div class="bar-fill" style="width:${(100 * (x.engagements || 0)) / maxE}%"></div></div>
                <div class="bar-track" style="flex:1"><div class="bar-fill" style="width:${(100 * (x.reach || x.articles || 0)) / maxCR}%;background:var(--series-2)"></div></div>
              </div>
              <div class="bar-value">${x.articles ? kfmt(x.reach || 0) : '— none'}</div>
            </div>`).join('')}
            <div class="note" style="margin-top:6px"><span class="chip-dot c0"></span> platform engagement · <span class="chip-dot c1"></span> coverage reach — releases with no right-hand bar earned no tracked coverage. Click a release for its 360°.</div>
          </div>`;
        }

        // 7 — top publications (Online / Print toggle) + journalists + SOV
        if (d.topPublications?.length) {
          html += `<div class="card" id="mon-pubs-card" data-mon-sec="publications"></div>`;
        }
        const g4 = [];
        if (d.topJournalists?.length) g4.push(`<div class="card" data-mon-sec="journalists"><div class="card-head"><h3>Top journalists — writing the coverage</h3>${listControls('mon-journalists')}</div>${drillRows(d.topJournalists, 'articles', 'journalist')}<div class="note" style="margin-top:6px">The bylines behind this period's articles — click one for their pieces</div></div>`);
        if (d.shareOfVoice?.length) {
          const totV = d.shareOfVoice.reduce((a, x) => a + (x.reach || x.articles || 0), 0) || 1;
          g4.push(`<div class="card" data-mon-sec="shareOfVoice"><div class="card-head"><h3>Share of voice — by reach</h3>${listControls('mon-brands')}</div>
            ${d.shareOfVoice.map((x) => `<div class="bar-row wide big clickable" data-mdrill="brand::${esc(x.name)}" title="${esc(x.name)}: ${fmtInt(x.articles)} articles${x.reach ? ` · ${kfmt(x.reach)} reach` : ''} — click for the articles">
              <div class="bar-label">${esc(x.name)}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (x.reach || x.articles || 0)) / Math.max(1, d.shareOfVoice[0].reach || d.shareOfVoice[0].articles || 1)}%"></div></div>
              <div class="bar-value">${Math.round((100 * (x.reach || x.articles || 0)) / totV)}%</div>
            </div>`).join('')}
            <div class="note" style="margin-top:6px">Competitor brands tracked in the monitoring data — share of the total tracked reach</div></div>`);
        }
        if (g4.length) html += `<div class="grid two">${g4.join('')}</div>`;

        // 8 — highlight articles
        if (d.topArticles?.length) {
          html += `<div class="card" data-mon-sec="articles"><div class="card-head"><h3>Highlight articles — by reach</h3>${listControls('mon-articles')}</div>
            <div style="overflow-x:auto"><table class="data">
            <thead><tr><th>Article</th><th>Publication</th><th>Date</th><th>Sentiment</th><th class="num">Reach</th></tr></thead>
            <tbody>${d.topArticles.map((a2) => `<tr>
              <td class="site-name">${esc(a2.title)}${a2.url ? ` <a class="ext" style="visibility:visible" href="${esc(a2.url)}" target="_blank" rel="noopener">↗</a>` : ''}</td>
              <td class="note">${esc(a2.publication || '—')}</td>
              <td class="note" style="white-space:nowrap">${esc(String(a2.date || '').slice(0, 10))}</td>
              <td>${a2.sentiment ? `<span class="delta ${String(a2.sentiment).toLowerCase().includes('pos') ? 'up' : String(a2.sentiment).toLowerCase().includes('neg') ? 'down' : 'flat'}">${esc(a2.sentiment)}</span>` : '—'}</td>
              <td class="num">${a2.reach ? kfmt(a2.reach) : '—'}</td>
            </tr>`).join('')}</tbody></table></div></div>`;
        }
        if (d.errors?.length) {
          html += `<div class="card" style="border-color:var(--warn-text)"><h3>Monitoring — partial</h3>
            ${d.errors.map((e2) => `<div class="note">⚠ ${esc(e2)}</div>`).join('')}
            ${isAdminUser() && d.schema ? `<div class="note" style="margin-top:6px">Schema detected: table <b>${esc(d.schema.table)}</b> · ${esc(Object.entries(d.schema.cols || {}).filter(([, v]) => v).map(([k, v]) => `${k}→${v}`).join(', '))}</div>` : ''}</div>`;
        }
        slot.innerHTML = html;
        if (d.daily?.length) {
          cadenceChart(document.getElementById('mon-chart'),
            d.daily.map((x) => ({ date: String(x.date).slice(0, 10), sessions: Number(x.reach) || 0, published: Number(x.articles) || 0, distributed: 0 })),
            (day) => renderMonArticlesModal(siteId, 'day', day),
            { line: 'Reach', bar1: 'Articles', bar2: '', axisL: 'reach' });
        }
        if (d.topPublications?.length) renderMonPubsCard(siteId, d.topPublications);
        bindListControls(slot, siteId, {
          'mon-brands': { rows: d.shareOfVoice || [] },
          'mon-mediaTypes': { rows: d.mediaTypes || [] },
          'mon-sentimentByRegion': { rows: sbrRows },
          'mon-releases': { rows: (d.releaseCoverage || []).filter(Boolean) },
        });
        slot.querySelectorAll('[data-mdrill]').forEach((row) =>
          row.addEventListener('click', () => {
            const [kind2, ...rest] = row.dataset.mdrill.split('::');
            renderMonArticlesModal(siteId, kind2, rest.join('::'));
          })
        );
        slot.querySelectorAll('[data-rcrel]').forEach((row) =>
          row.addEventListener('click', () => renderLegacyReleaseModal(siteId, row.dataset.rcrel))
        );
        const mktBtn = document.getElementById('mon-market-btn');
        const mktPop = document.getElementById('mon-market-pop');
        if (mktBtn && mktPop) {
          mktBtn.addEventListener('click', (e2) => { e2.stopPropagation(); mktPop.hidden = !mktPop.hidden; if (!mktPop.hidden) placePop(mktBtn, mktPop); });
          mktPop.addEventListener('click', (e2) => e2.stopPropagation());
          document.addEventListener('click', () => { mktPop.hidden = true; }, { once: true });
          const all = mktPop.querySelector('#mkt-all');
          all.addEventListener('change', () => { if (all.checked) mktPop.querySelectorAll('.mkt-cb, .mkt-rg-cb').forEach((c2) => (c2.checked = false)); });
          const syncAll = () => { if (![...mktPop.querySelectorAll('.mkt-cb')].some((x) => x.checked)) all.checked = true; };
          mktPop.querySelectorAll('.mkt-cb').forEach((c2) =>
            c2.addEventListener('change', () => { if (c2.checked) all.checked = false; syncAll(); })
          );
          // A REGION row toggles all of its markets at once. Member ids are
          // '|'-separated because a MERGED market's own id can contain commas
          // ('1,27' for UK + United Kingdom) — matching on full values keeps
          // merged markets toggling correctly.
          mktPop.querySelectorAll('.mkt-rg-cb').forEach((rc2) =>
            rc2.addEventListener('change', () => {
              const ids = new Set(String(rc2.dataset.ids || '').split('|').filter(Boolean));
              mktPop.querySelectorAll('.mkt-cb').forEach((c2) => { if (ids.has(c2.value)) c2.checked = rc2.checked; });
              if (rc2.checked) all.checked = false;
              syncAll();
            })
          );
          // Live search over regions AND markets.
          const srch = mktPop.querySelector('#mkt-search');
          if (srch) srch.addEventListener('input', () => {
            const q2 = srch.value.toLowerCase().trim();
            mktPop.querySelectorAll('.mkt-opt.mkt-region, .mkt-opt.mkt-under').forEach((row) => {
              const hit = !q2 || row.textContent.toLowerCase().includes(q2) || String(row.dataset.rg || '').toLowerCase().includes(q2);
              row.style.display = hit ? '' : 'none';
            });
          });
          mktPop.querySelector('#mkt-apply').addEventListener('click', () => {
            const picked = [...mktPop.querySelectorAll('.mkt-cb')].filter((x) => x.checked).map((x) => x.value);
            state.monMarket[siteId] = picked.join(',');
            renderMonitoringCards(siteId);
          });
        }
        const dlMon = document.getElementById('mon-dl');
        if (dlMon) dlMon.addEventListener('click', async () => {
          dlMon.disabled = true;
          dlMon.innerHTML = '<span class="chat-spin" style="width:12px;height:12px;display:inline-block;vertical-align:-2px;margin-right:6px"></span>Building the report — AI key insight, all graphs, article screenshots… (can take up to a minute)';
          try {
            const r = await api('/api/reports/generate', { method: 'POST', body: JSON.stringify({ scope: siteId, range: state.range, style: 'monitoring', market: state.monMarket?.[siteId] || null }) });
            window.open('/reports/' + encodeURIComponent(r.pdfFilename || r.filename), '_blank');
            dlMon.innerHTML = '✓ Report ready — opened in a new tab (also saved in Reports)';
            setTimeout(() => { dlMon.disabled = false; dlMon.innerHTML = '⬇ Download the myNewspress report'; }, 4000);
          } catch (err) {
            dlMon.disabled = false; dlMon.innerHTML = '✗ ' + esc(err.message.slice(0, 60));
            setTimeout(() => { dlMon.innerHTML = '⬇ Download the myNewspress report'; }, 4000);
          }
        });
        // Cached narrative from an earlier click? Re-apply it after re-render.
        state.monNarr = state.monNarr || {};
        const narrKey = siteId + '|' + state.range + '|' + (selMarket || 'all');
        if (state.monNarr[narrKey]) {
          applyMonNarrative(slot, state.monNarr[narrKey]);
          const b0 = document.getElementById('mon-ai');
          if (b0) b0.style.display = 'none';
        }
        const aiBtn = document.getElementById('mon-ai');
        if (aiBtn) aiBtn.addEventListener('click', async () => {
          aiBtn.disabled = true; aiBtn.textContent = 'Reading every graph and writing the analyst commentary…';
          try {
            const r = await api(`/api/site/${encodeURIComponent(siteId)}/monitoring-narrative?range=${encodeURIComponent(state.range)}${selMarket ? `&market=${encodeURIComponent(selMarket)}` : ''}`, { method: 'POST', body: '{}' });
            state.monNarr[narrKey] = r.sections;
            applyMonNarrative(slot, r.sections);
            aiBtn.style.display = 'none';
          } catch (err) {
            aiBtn.disabled = false; aiBtn.textContent = '✦ Write the analyst narrative — KEY INSIGHT + notes under every graph';
            document.getElementById('mon-ai-out').innerHTML = `<div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
          }
        });
      })
      .catch(() => { slot.innerHTML = ''; });
  }

  // Top publications with an All / Online / Print filter (Hyundai-doc style).
  function renderMonPubsCard(siteId, pubs) {
    const el = document.getElementById('mon-pubs-card');
    if (!el) return;
    const sel = state.monPubSel || 'all';
    const types = [...new Set(pubs.map((p) => String(p.mediaType || '').toLowerCase()).filter(Boolean))];
    const rows = sel === 'all' ? pubs : pubs.filter((p) => String(p.mediaType || '').toLowerCase() === sel);
    el.innerHTML = `
      <div class="card-head">
        <h3>Top publications — by reach</h3>
        <span style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
        ${types.length > 1 ? `<div class="metric-toggle">
          <button data-mp="all" class="${sel === 'all' ? 'active' : ''}">All</button>
          ${types.map((t2) => `<button data-mp="${esc(t2)}" class="${sel === t2 ? 'active' : ''}">${esc(t2.charAt(0).toUpperCase() + t2.slice(1))}</button>`).join('')}
        </div>` : ''}
        ${listControls('mon-publications')}
        </span>
      </div>
      ${rows.length ? `<div style="overflow-x:auto"><table class="data">
      <thead><tr><th>Publication</th><th>Type</th><th class="num">Articles</th><th class="num">Reach</th></tr></thead>
      <tbody>${rows.map((p) => `<tr class="clickable" data-mdrill="publication::${esc(p.name)}" title="Click for ${esc(p.name)}'s articles">
        <td class="site-name">${esc(p.name)}</td>
        <td class="note">${esc([p.mediaType, p.tier].filter(Boolean).join(' · ') || '—')}</td>
        <td class="num">${fmtInt(p.articles)}</td><td class="num">${p.reach ? kfmt(p.reach) : '—'}</td>
      </tr>`).join('')}</tbody></table></div>` : '<div class="note">No publications of this type.</div>'}`;
    el.querySelectorAll('[data-mp]').forEach((b) =>
      b.addEventListener('click', () => { state.monPubSel = b.dataset.mp; renderMonPubsCard(siteId, pubs); })
    );
    bindListControls(el, siteId);
    el.querySelectorAll('[data-mdrill]').forEach((row) =>
      row.addEventListener('click', () => {
        const [kind2, ...rest] = row.dataset.mdrill.split('::');
        renderMonArticlesModal(siteId, kind2, rest.join('::'));
      })
    );
    if (state.monNarrLive?.publications?.length) el.insertAdjacentHTML('beforeend', monNarrHtml(state.monNarrLive.publications));
  }

  // Journalist 360° — every release they touched, publications, email events.
  async function renderJournalistModal(siteId, uid, email, name) {
    const body = openModal(`<h3>🗞 ${esc(name || email || 'Journalist')}</h3><div class="note">Pulling their full activity…</div>`);
    try {
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/legacy-person?uid=${encodeURIComponent(uid)}&email=${encodeURIComponent(email || '')}`);
      const a = d.activity;
      if (!a) { body.innerHTML = `<h3>🗞 ${esc(name)}</h3><div class="note">No activity found in the reports database.</div>`; return; }
      const totV = (a.releases || []).reduce((s, r) => s + Number(r.views || 0), 0);
      const totD = (a.releases || []).reduce((s, r) => s + Number(r.downloads || 0), 0);
      // aggregate email events per campaign: 7=delivered 1=open 2=click 3=unsub
      const camps = {};
      (a.emailEvents || []).forEach((e2) => {
        const c = camps[e2.campaignId] || (camps[e2.campaignId] = { opens: 0, clicks: 0, delivered: 0, last: null });
        if (Number(e2.event) === 1) c.opens++;
        if (Number(e2.event) === 2) c.clicks++;
        if (Number(e2.event) === 7) c.delivered++;
        if (!c.last || String(e2.date) > c.last) c.last = String(e2.date || '');
      });
      const campRows = Object.entries(camps).sort((x, y) => (y[1].last || '').localeCompare(x[1].last || '')).slice(0, 12);
      body.innerHTML = `
        <h3 style="margin-bottom:4px">🗞 ${esc(name || email)}</h3>
        <div class="note" style="margin-bottom:8px">${esc(email || '')}${a.publications?.length ? ' · ' + esc(a.publications.join(', ')) : ''}</div>
        <div class="facts-row">
          <span class="fact-pill">📄 <b>${fmtInt(totV)}</b> release views</span>
          <span class="fact-pill">📥 <b>${fmtInt(totD)}</b> downloads</span>
          ${a.publications?.length ? `<span class="fact-pill">📰 <b>${a.publications.length}</b> publication${a.publications.length > 1 ? 's' : ''}</span>` : ''}
          ${campRows.length ? `<span class="fact-pill">📧 <b>${campRows.length}</b> email campaigns engaged</span>` : ''}
        </div>
        ${a.releases?.length ? `<h3 style="margin-top:12px">Releases they viewed / downloaded</h3>
          <table class="data"><thead><tr><th>Release</th><th class="num">Views</th><th class="num">Downloads</th><th>Last seen</th></tr></thead>
          <tbody>${a.releases.map((r) => `<tr class="clickable" data-jrel="${esc(String(r.id))}">
            <td class="site-name">${esc(r.title || '#' + r.id)}</td>
            <td class="num">${fmtInt(r.views)}</td><td class="num">${fmtInt(r.downloads)}</td>
            <td class="note">${esc(String(r.lastSeen || '—').slice(0, 10))}</td></tr>`).join('')}</tbody></table>` : '<div class="note" style="margin-top:10px">No tracked release views yet.</div>'}
        ${campRows.length ? `<h3 style="margin-top:12px">Email engagement</h3>
          <table class="data"><thead><tr><th>Campaign</th><th class="num">Opens</th><th class="num">Clicks</th><th>Last event</th></tr></thead>
          <tbody>${campRows.map(([cid, c]) => `<tr><td class="note">#${esc(cid)}</td>
            <td class="num">${fmtInt(c.opens)}</td><td class="num">${fmtInt(c.clicks)}</td>
            <td class="note">${esc((c.last || '—').slice(0, 10))}</td></tr>`).join('')}</tbody></table>` : ''}`;
      body.querySelectorAll('[data-jrel]').forEach((r2) =>
        r2.addEventListener('click', () => renderLegacyReleaseModal(siteId, r2.dataset.jrel))
      );
    } catch (err) {
      body.innerHTML = `<h3>🗞 ${esc(name)}</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // Publication drill — its journalists (clickable) and top releases read.
  async function renderPublicationModal(siteId, pid, name) {
    const body = openModal(`<h3>📰 ${esc(name || 'Publication')}</h3><div class="note">Loading the outlet's activity…</div>`);
    try {
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/legacy-publication?pid=${encodeURIComponent(pid)}&range=${encodeURIComponent(state.range)}`);
      const p = d.publication;
      if (!p) { body.innerHTML = `<h3>📰 ${esc(name)}</h3><div class="note">Not found in the reports database.</div>`; return; }
      const active = (p.journalists || []).filter((j) => Number(j.events) > 0);
      body.innerHTML = `
        <h3 style="margin-bottom:4px">📰 ${esc(p.name)}</h3>
        <div class="facts-row">
          <span class="fact-pill">🗞 <b>${(p.journalists || []).length}</b> registered journalists</span>
          <span class="fact-pill">⚡ <b>${active.length}</b> active this period</span>
        </div>
        ${p.journalists?.length ? `<h3 style="margin-top:12px">Journalists — activity this period</h3>
          <table class="data"><thead><tr><th>Journalist</th><th class="num">Views</th><th class="num">Downloads</th><th>Last active</th></tr></thead>
          <tbody>${p.journalists.slice(0, 25).map((j) => `<tr class="clickable" data-pj="${esc(String(j.userId))}" data-pje="${esc(j.email || '')}" data-pjn="${esc(j.name || j.email || 'Unknown')}">
            <td class="site-name">${esc(j.name || j.email || 'Unknown')}<div class="note" style="font-weight:400">${esc(j.email || '')}</div></td>
            <td class="num">${fmtInt(j.views)}</td><td class="num">${fmtInt(j.downloads)}</td>
            <td class="note">${esc(String(j.lastSeen || '—').slice(0, 10))}</td></tr>`).join('')}</tbody></table>` : '<div class="note">No journalists linked to this outlet.</div>'}
        ${p.topReleases?.length ? `<h3 style="margin-top:12px">What this outlet read most</h3>
          ${p.topReleases.map((r) => `<div class="bar-row clickable" data-prel="${esc(String(r.id))}">
            <div class="bar-label" title="${esc(r.title || '')}">${esc(r.title || '#' + r.id)}</div>
            <div class="bar-track"><div class="bar-fill" style="width:${(100 * r.events) / Math.max(1, p.topReleases[0].events)}%"></div></div>
            <div class="bar-value">${fmtInt(r.events)}</div></div>`).join('')}` : ''}`;
      body.querySelectorAll('[data-pj]').forEach((r2) =>
        r2.addEventListener('click', () => renderJournalistModal(siteId, r2.dataset.pj, r2.dataset.pje, r2.dataset.pjn))
      );
      body.querySelectorAll('[data-prel]').forEach((r2) =>
        r2.addEventListener('click', () => renderLegacyReleaseModal(siteId, r2.dataset.prel))
      );
    } catch (err) {
      body.innerHTML = `<h3>📰 ${esc(name)}</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // Media asset drill — full counters + the releases it belongs to.
  async function renderMediaModal(siteId, mid) {
    const body = openModal(`<h3>🖼 Media asset</h3><div class="note">Loading…</div>`);
    try {
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/legacy-media?mid=${encodeURIComponent(mid)}`);
      const m = d.media;
      if (!m) { body.innerHTML = `<h3>🖼 Media asset</h3><div class="note">Not found in the reports database.</div>`; return; }
      body.innerHTML = `
        <h3 style="margin-bottom:4px">🖼 ${esc(m.name)}</h3>
        <div class="facts-row">
          <span class="fact-pill">🏷 ${esc(m.type || 'asset')}</span>
          <span class="fact-pill">👁 <b>${fmtInt(m.viewed)}</b> views</span>
          <span class="fact-pill">📥 <b>${fmtInt(m.downloaded)}</b> downloads</span>
        </div>
        ${m.releases?.length ? `<h3 style="margin-top:12px">Used in these releases</h3>
          <table class="data"><thead><tr><th>Release</th><th>Published</th></tr></thead>
          <tbody>${m.releases.map((r) => `<tr class="clickable" data-mrel="${esc(String(r.id))}">
            <td class="site-name">${esc(r.title || '#' + r.id)}</td>
            <td class="note">${esc(String(r.published || '—').slice(0, 10))}</td></tr>`).join('')}</tbody></table>` : '<div class="note" style="margin-top:10px">Not linked to any release.</div>'}`;
      body.querySelectorAll('[data-mrel]').forEach((r2) =>
        r2.addEventListener('click', () => renderLegacyReleaseModal(siteId, r2.dataset.mrel))
      );
    } catch (err) {
      body.innerHTML = `<h3>🖼 Media asset</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // Release 360° — platform record + GA4 traffic + email, one modal.
  async function renderLegacyReleaseModal(siteId, rid) {
    const body = openModal(`<h3>Release #${esc(rid)}</h3><div class="note">Assembling the 360° view…</div>`);
    try {
      const [lr, siteCfg] = [await api(`/api/site/${encodeURIComponent(siteId)}/legacy-release?rid=${encodeURIComponent(rid)}&range=${encodeURIComponent(state.range)}`), (state.config?.sites || []).find((x) => x.id === siteId)];
      const r = lr.release;
      if (!r) { body.innerHTML = `<h3>Release #${esc(rid)}</h3><div class="note">Not found in the reports database.</div>`; return; }
      // find its GA4 page by id
      let ga = null;
      try {
        const sr = await api(`/api/site/${encodeURIComponent(siteId)}/search-pages?q=${encodeURIComponent(rid)}&range=${encodeURIComponent(state.range)}`);
        ga = sr.rows?.[0] || null;
      } catch { /* optional */ }
      body.innerHTML = `
        <h3 style="margin-bottom:4px">${esc(r.title || 'Release #' + rid)}</h3>
        <div class="note" style="margin-bottom:8px">#${esc(String(r.id))}${r.companyName ? ' · ' + esc(r.companyName) : ''}${r.published ? ' · published ' + esc(String(r.published).slice(0, 10)) : ''}${r.categories?.length ? ' · ' + esc(r.categories.join(', ')) : ''}</div>
        <div class="facts-row">
          <span class="fact-pill">📄 <b>${fmtInt(r.viewed)}</b> platform views</span>
          ${r.downloaded != null ? `<span class="fact-pill">📥 <b>${fmtInt(r.downloaded)}</b> downloads</span>` : ''}
          ${r.basket != null ? `<span class="fact-pill">🧺 <b>${fmtInt(r.basket)}</b> basket adds</span>` : ''}
          ${ga ? `<span class="fact-pill">🌐 <b>${fmtInt(ga.screenPageViews)}</b> GA4 views (${esc(ga.dimensionValue)})</span>` : ''}
        </div>
        ${(r.emails || []).filter((e2) => e2.dedicated !== false).map((e2) => `<div class="email-join">📧 <b>${esc(e2.title || e2.campaignId)}</b>${e2.sentDate ? ' — sent ' + esc(String(e2.sentDate).slice(0, 10)) : ''}: ${fmtInt(e2.recipients)} recipients → ${fmtInt(e2.opened)} opens → ${fmtInt(e2.clicked)} clicks <span class="prov-badge">db</span></div>`).join('')}
        ${(r.emails || []).some((e2) => e2.dedicated === false) ? `<div class="note" style="margin-top:6px">Also featured in multi-release sends (totals are for the whole send, not this release):
          ${(r.emails || []).filter((e2) => e2.dedicated === false).map((e2) => `<div>• ${esc(e2.title || e2.campaignId)} — ${esc(String(e2.sentDate || '').slice(0, 10))}, ${fmtInt(e2.recipients)} recipients</div>`).join('')}</div>` : ''}
        ${r.dailyEvents?.length > 1 ? `<h3 style="margin-top:12px">Interest over time (platform events/day)</h3>
          <div style="display:flex;align-items:flex-end;gap:2px;height:48px;margin:6px 0 2px">
            ${(() => { const mx = Math.max(1, ...r.dailyEvents.map((x) => x.events)); return r.dailyEvents.map((x) => `<div title="${esc(String(x.date))}: ${x.events} events" style="flex:1;max-width:14px;height:${Math.max(3, (44 * x.events) / mx)}px;background:var(--series-1);border-radius:2px 2px 0 0;opacity:.85"></div>`).join(''); })()}
          </div>` : ''}
        ${r.readers?.length ? `<h3 style="margin-top:12px">Who read it — registered readers (${r.readers.length})</h3>
          <div style="overflow-x:auto;max-height:260px;overflow-y:auto"><table class="data">
          <thead><tr><th>Reader</th><th class="num">Views</th><th class="num">Downloads</th><th>Last seen</th></tr></thead>
          <tbody>${r.readers.map((rd) => `<tr class="clickable" data-r360j="${esc(String(rd.userId))}" data-r360e="${esc(rd.email || '')}" data-r360n="${esc(rd.name || rd.email || 'Unknown')}">
            <td class="site-name">${esc(rd.name || rd.email || 'Unknown')}<div class="note" style="font-weight:400">${esc(rd.email || '')}</div></td>
            <td class="num">${fmtInt(rd.views)}</td><td class="num">${fmtInt(rd.downloads)}</td>
            <td class="note">${esc(String(rd.lastSeen || '—').slice(0, 10))}</td></tr>`).join('')}</tbody></table></div>` : ''}
        ${r.assets?.length ? `<h3 style="margin-top:12px">Assets in this release</h3>
          ${r.assets.map((m) => `<div class="bar-row clickable" data-r360m="${esc(String(m.id))}">
            <div class="bar-label" title="${esc(m.name)}">${m.type === 'document' ? '📄' : m.type === 'video' ? '🎬' : '🖼'} ${esc(m.name)}${m.variants > 1 ? ` <span class="note">(${m.variants} files)</span>` : ''}</div>
            <div class="bar-track"><div class="bar-fill" style="width:${(100 * (m.downloaded || 0)) / Math.max(1, r.assets[0].downloaded || 1)}%"></div></div>
            <div class="bar-value">${fmtInt(m.downloaded)}</div></div>`).join('')}
          <div class="note" style="margin-top:4px">Downloads per asset — click one for its full stats</div>` : ''}
        ${r.countries?.length ? `<h3 style="margin-top:12px">Reader countries (platform events)</h3>${barList(r.countries.map((c) => ({ dimensionValue: c.country, events: c.events })), 'events')}` : ''}
        ${ga ? `<div class="note" style="margin-top:10px">Click below for the full GA4 traffic dossier of this page:</div>
          <button class="back-link" id="open-ga">Open GA4 dossier — ${esc(ga.dimensionValue)}</button>` : ''}`;
      body.querySelector('#open-ga')?.addEventListener('click', () => renderPageDetail(siteId, ga.dimensionValue));
      body.querySelectorAll('[data-r360j]').forEach((row) =>
        row.addEventListener('click', () => renderJournalistModal(siteId, row.dataset.r360j, row.dataset.r360e, row.dataset.r360n))
      );
      body.querySelectorAll('[data-r360m]').forEach((row) =>
        row.addEventListener('click', () => renderMediaModal(siteId, row.dataset.r360m))
      );
    } catch (err) {
      body.innerHTML = `<h3>Release #${esc(rid)}</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // ── PR snapshot — the story at a glance, no drilling needed ──────────
  function snapChip(id, icon, value, label, target) {
    return `<div class="snap" id="${id}" data-target="${target || ''}" title="Click to jump to this section"><div class="snap-ic">${icon}</div><div style="min-width:0"><div class="snap-v">${value}</div><div class="snap-l">${label}</div></div></div>`;
  }
  function setSnap(id, value, label) {
    const el = document.getElementById(id);
    if (!el) return;
    el.querySelector('.snap-v').innerHTML = value;
    const l = el.querySelector('.snap-l');
    if (label) { l.innerHTML = label; l.title = l.textContent; }
    el.querySelector('.snap-v').title = el.querySelector('.snap-v').textContent;
  }
  function hideSnap(id) { document.getElementById(id)?.remove(); }
  function jumpToCard(targetId) {
    const t = document.getElementById(targetId);
    if (!t) return;
    t.scrollIntoView({ behavior: 'smooth', block: 'center' });
    t.classList.remove('card-flash');
    void t.offsetWidth; // restart animation
    t.classList.add('card-flash');
    setTimeout(() => t.classList.remove('card-flash'), 2000);
  }

  function renderPrSnapshot(data) {
    const el = document.getElementById('pr-snap');
    if (!el) return;
    const peak = data.trend.reduce((a, b) => (b.sessions > (a?.sessions ?? -1) ? b : a), null);
    const topCh = data.channels[0];
    const chTotal = data.channels.reduce((a, c) => a + (c.sessions || 0), 0);
    const ctryTotal = data.countries.reduce((a, c) => a + (c.sessions || 0), 0);
    const intl = ctryTotal ? 1 - (data.countries[0]?.sessions || 0) / ctryTotal : null;
    el.innerHTML = `
      <h3>PR snapshot</h3>
      <div class="snap-row">
        ${snapChip('snap-story', '🏆', '<span class="note">analysing…</span>', 'Top story · last 90 days', 'releases-card')}
        ${peak ? snapChip('snap-peak', '📈', fmtInt(peak.sessions), `Best day · ${esc(peak.date.slice(5))}`, 'trend-site') : ''}
        ${topCh ? snapChip('snap-channel', '📣', esc(topCh.dimensionValue), `Top channel · ${chTotal ? Math.round((100 * topCh.sessions) / chTotal) : 0}% of traffic`, 'channels-card') : ''}
        ${intl != null ? snapChip('snap-intl', '🌍', fmtPct(intl), `International reach (outside ${esc(data.countries[0]?.dimensionValue || '')})`, 'countries-card') : ''}
        ${snapChip('snap-radar', '🔗', '<span class="note">scanning…</span>', 'New coverage sources', 'radar-card')}
        ${snapChip('snap-email', '📧', '<span class="note">checking…</span>', 'Email reach this period', 'email-card')}
      </div>`;
    el.querySelectorAll('.snap').forEach((chip) =>
      chip.addEventListener('click', () => {
        // Top story opens its dossier directly once the release loader has
        // stamped the path; every chip otherwise jumps to its section.
        if (chip.id === 'snap-story' && chip.dataset.path) {
          renderPageDetail(route().id, chip.dataset.path);
          return;
        }
        if (chip.dataset.target) jumpToCard(chip.dataset.target);
      })
    );
  }

  // Re-init the daily-traffic chart with ALL known markers (email sends +
  // traffic peaks), so whichever card loads last doesn't wipe the other's.
  function applySiteMarkers(siteId) {
    const t = state.siteTrend || [];
    if (!t.length || !document.getElementById('trend-site')) return;
    const ms = [...(state.siteMarkers?.peaks || []), ...(state.siteMarkers?.email || [])];
    initMetricChart('trend-site', t, ms, 250, (day) => renderDayModal(siteId, day));
  }

  // ── "What drove the peaks" — annotated spike days on the daily chart ──
  function renderPeaksCard(siteId) {
    const el = document.getElementById('peaks-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/peaks?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        const peaks = d.peaks || [];
        if (!peaks.length) { el.style.display = 'none'; return; }
        const ICON = { release: '📰', email: '📧', page: '📈' };
        const max = Math.max(1, ...peaks.map((p) => p.sessions || 0));
        el.style.display = '';
        el.innerHTML = `
          <div class="card-head"><h3>What drove the traffic peaks</h3></div>
          ${peaks.map((p, i) => `
            <div class="bar-row wide clickable" data-pk="${i}" title="${esc(p.date)}: ${esc(p.label)} — click to open ${p.releaseId ? 'this release' : 'that day'}">
              <div class="bar-label">${ICON[p.kind] || '📈'} <b>${esc(p.date)}</b> — ${esc(p.label)}${p.email && p.kind !== 'email' ? ' <span class="note">+ email send</span>' : ''}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (p.sessions || 0)) / max}%"></div></div>
              <div class="bar-value">${fmtInt(p.sessions)}</div>
            </div>`).join('')}
          <div class="note" style="margin-top:6px">Spike days vs the period's typical day, attributed to the top release, page or email send that day — annotated on the chart above. Click one to drill in.</div>`;
        el.querySelectorAll('[data-pk]').forEach((row) =>
          row.addEventListener('click', () => {
            const p = peaks[Number(row.dataset.pk)];
            if (p.releaseId) renderLegacyReleaseModal(siteId, String(p.releaseId));
            else renderDayModal(siteId, p.date);
          })
        );
        state.siteMarkers = state.siteMarkers || { email: [], peaks: [] };
        state.siteMarkers.peaks = peaks.map((p) => ({
          date: p.date,
          anno: (p.kind === 'email' ? '📧 ' : '') + p.label,
          label: `${p.date}: ${p.label}${p.email && p.kind !== 'email' ? ' (+ email send)' : ''} — ${fmtInt(p.sessions)} sessions`,
        }));
        applySiteMarkers(siteId);
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // Generic "✦ AI summary" button under a card — one endpoint per card key.
  function aiCardHtml(cardKey, labelTxt) {
    if (!state.config?.chatConfigured) return '';
    return `<button class="back-link ai-card-btn" data-aicard="${cardKey}" style="margin-top:8px">✦ ${esc(labelTxt || 'AI summary of this graph')}</button><div class="ai-card-out" data-aiout="${cardKey}"></div>`;
  }
  function bindAiCard(el, siteId) {
    el.querySelectorAll('.ai-card-btn:not([data-bound])').forEach((btn) => {
      btn.dataset.bound = '1';
      btn.addEventListener('click', async () => {
        const key = btn.dataset.aicard;
        const out = el.querySelector(`[data-aiout="${key}"]`);
        btn.disabled = true; btn.textContent = 'Reading this graph and writing the insight…';
        try {
          const r = await api(`/api/site/${encodeURIComponent(siteId)}/card-summary`, {
            method: 'POST', body: JSON.stringify({ card: key, range: state.range }),
          });
          out.innerHTML = `<div class="msg assistant ai-summary">${md(r.summary)}</div>`;
          btn.style.display = 'none';
        } catch (err) {
          btn.disabled = false; btn.textContent = '✦ AI summary of this graph';
          out.innerHTML = `<div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
        }
      });
    });
  }

  // ── GA4 ↔ platform reconciliation: why the numbers differ ─────────────
  function renderReconCard(siteId) {
    const el = document.getElementById('recon-card');
    if (!el) return;
    const siteCfg = (state.config?.sites || []).find((x) => x.id === siteId);
    if (!siteCfg?.kind) { el.style.display = 'none'; return; }
    api(`/api/site/${encodeURIComponent(siteId)}/recon?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        const r = d.recon;
        if (!r || !r.rows?.length) { el.style.display = 'none'; return; }
        el.style.display = '';
        const vis = r.visibility != null ? Math.round(r.visibility * 100) : null;
        // Scale on the BIGGEST value of either series — bars can never overflow.
        const maxAll = Math.max(1, ...r.rows.flatMap((x) => [x.platformViews, x.gaViews]));
        el.innerHTML = `
          <div class="card-head"><h3>Platform vs GA4 — the same releases, both counts</h3></div>
          <div class="facts-row" style="margin-bottom:8px">
            ${vis != null ? `<span class="fact-pill">👁 GA4 typically sees <b>~${vis}%</b> of real platform traffic here</span>` : ''}
            <span class="fact-pill">🖥 Platform: <b>${fmtInt(r.totals.platform)}</b> views (server-side, complete)</span>
            <span class="fact-pill">📊 GA4: <b>${fmtInt(r.totals.ga)}</b> pageviews (consented visitors)</span>
          </div>
          ${r.rows.map((x) => {
            const ratio = x.platformViews ? Math.round((100 * x.gaViews) / x.platformViews) : null;
            return `
            <div class="bar-row wide clickable" data-rcn="${esc(String(x.id))}" title="${esc(x.title)} — platform ${fmtInt(x.platformViews)} · GA4 ${fmtInt(x.gaViews)}${ratio != null ? ` (${ratio}%)` : ''} — click to open this release">
              <div class="bar-label">${esc(x.title)}</div>
              <div style="display:flex;flex-direction:column;gap:2px;min-width:0">
                <div class="bar-track" style="height:9px"><div class="bar-fill" style="width:${Math.min(100, (100 * x.platformViews) / maxAll)}%"></div></div>
                <div class="bar-track" style="height:9px"><div class="bar-fill" style="width:${Math.min(100, (100 * x.gaViews) / maxAll)}%;background:var(--series-2)"></div></div>
              </div>
              <div class="bar-value">${x.gaHigher ? '<span class="delta up" title="GA4 recorded MORE than the platform — this page also pulls consumer/repeat traffic beyond registered press views">GA4 +' + Math.max(0, (ratio || 100) - 100) + '%</span>' : ratio != null ? ratio + '%' : '—'}</div>
            </div>`;
          }).join('')}
          <div class="note" style="margin-top:8px"><span class="chip-dot c0"></span> platform views (counted server-side — every visitor) · <span class="chip-dot c1"></span> GA4 pageviews (only visitors who accept analytics cookies under GDPR/CCPA, minus ad-blockers).
          <b style="color:var(--text-primary)">The platform number is the true press count</b> — use GA4 for what only it knows: sources, devices, journeys, new vs returning.${r.outliers ? ` Where GA4 shows <b>more</b> than the platform, that release's page is also reaching consumers and repeat visitors beyond the registered press audience — extra reach, not an error.` : ''}</div>
          ${aiCardHtml('recon', 'AI summary — explain this comparison')}`;
        el.querySelectorAll('[data-rcn]').forEach((row) =>
          row.addEventListener('click', () => renderLegacyReleaseModal(siteId, row.dataset.rcn))
        );
        bindAiCard(el, siteId);
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── Site heat map — squarified treemap with drill-down ────────────────
  // Classic squarify: lay items (sorted desc) into rows that keep cells as
  // close to square as possible. Returns [{x,y,w,h,item}].
  function squarify(items, X, Y, W, H) {
    const out = [];
    let x = X, y = Y, w = W, h = H;
    let row = [], rowArea = 0;
    const totalArea = items.reduce((a, i) => a + i.area, 0) || 1;
    const scale = (W * H) / totalArea;
    const worst = (r, len) => {
      if (!r.length) return Infinity;
      const sum = r.reduce((a, i) => a + i.area * scale, 0);
      const mx = Math.max(...r.map((i) => i.area * scale));
      const mn = Math.min(...r.map((i) => i.area * scale));
      const s2 = sum * sum;
      return Math.max((len * len * mx) / s2, s2 / (len * len * mn));
    };
    const layoutRow = (r) => {
      const sum = r.reduce((a, i) => a + i.area * scale, 0);
      const horiz = w >= h; // lay the row along the SHORTER side
      const len = horiz ? h : w;
      const thick = sum / Math.max(1, len);
      let off = 0;
      for (const it of r) {
        const cell = (it.area * scale) / Math.max(1, thick);
        if (horiz) out.push({ x, y: y + off, w: thick, h: cell, item: it });
        else out.push({ x: x + off, y, w: cell, h: thick, item: it });
        off += cell;
      }
      if (horiz) { x += thick; w -= thick; } else { y += thick; h -= thick; }
    };
    for (const it of items) {
      const len = w >= h ? h : w;
      if (worst([...row, it], len) <= worst(row, len) || !row.length) row.push(it);
      else { layoutRow(row); row = [it]; }
    }
    if (row.length) layoutRow(row);
    return out;
  }

  function renderHeatmapCard(siteId) {
    const el = document.getElementById('heatmap-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/list?what=pages&range=${encodeURIComponent(state.range)}&limit=500`)
      .then((d) => {
        const pages = d.rows || [];
        if (pages.length < 4) { el.style.display = 'none'; return; }
        el.style.display = '';
        state.hmPrefix = null; // section view by default on each load
        const draw = () => {
          const prefix = state.hmPrefix;
          // Group: sections (depth 1) or children of the drilled section
          const groups = new Map();
          for (const p of pages) {
            const path = String(p.dimensionValue);
            let key = null, label = null, leaf = false;
            if (!prefix) {
              const seg = path.split('/').filter(Boolean)[0] || '';
              key = '/' + seg;
              label = seg ? '/' + seg : 'Homepage';
            } else {
              if (!(path === prefix || path.startsWith(prefix + '/'))) continue;
              const rest = path.slice(prefix.length).split('/').filter(Boolean);
              if (!rest.length) { key = path; label = '(section page)'; leaf = true; }
              else if (rest.length === 1) { key = prefix + '/' + rest[0]; label = '/' + rest[0]; leaf = true; }
              else { key = prefix + '/' + rest[0]; label = '/' + rest[0] + '/…'; }
            }
            const g = groups.get(key) || { key, label, views: 0, pages: 0, leaf, samplePath: path };
            g.views += p.screenPageViews || 0;
            g.pages++;
            if (g.pages > 1) g.leaf = false;
            groups.set(key, g);
          }
          let items = [...groups.values()].sort((a, b) => b.views - a.views);
          if (items.length > 16) {
            const rest = items.slice(15);
            items = items.slice(0, 15);
            items.push({ key: '__other', label: `+${rest.length} more`, views: rest.reduce((a, x) => a + x.views, 0), pages: rest.reduce((a, x) => a + x.pages, 0), other: true });
          }
          const tot = items.reduce((a, s) => a + s.views, 0) || 1;
          const maxV = Math.max(1, ...items.map((s) => s.views));
          const W = Math.max(el.clientWidth - 40 || 900, 480), H = 230;
          const cells = squarify(items.map((s) => ({ ...s, area: Math.max(s.views, tot * 0.005) })), 0, 0, W, H);
          const gid = 'hmg' + Date.now() % 1e5;
          el.innerHTML = `
            <div class="card-head">
              <h3>Site heat map — where people go</h3>
              <div style="display:flex;gap:8px;align-items:center">
                ${prefix ? `<button class="mini-btn" id="hm-back">← All sections</button><span class="crumb">${esc(prefix)}</span>` : ''}
                <span class="note" style="display:flex;align-items:center;gap:5px">cool
                  <span style="display:inline-block;width:70px;height:9px;border-radius:5px;background:linear-gradient(90deg, color-mix(in srgb, var(--series-1) 16%, var(--surface-1)), var(--series-1))"></span>
                hot</span>
              </div>
            </div>
            <svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" style="display:block">
              <defs><filter id="${gid}" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="1" stdDeviation="1.2" flood-opacity="0.18"/></filter></defs>
              ${cells.map((c, i) => {
                const s = c.item;
                const heat = Math.round((0.16 + 0.84 * Math.pow(s.views / maxV, 0.6)) * 100);
                const share = ((100 * s.views) / tot);
                const big = c.w > 78 && c.h > 40;
                const mid = c.w > 52 && c.h > 26;
                const dark = heat > 55;
                return `<g class="hm-g" data-hi="${i}" style="cursor:pointer">
                  <rect x="${(c.x + 1.5).toFixed(1)}" y="${(c.y + 1.5).toFixed(1)}" width="${Math.max(1, c.w - 3).toFixed(1)}" height="${Math.max(1, c.h - 3).toFixed(1)}" rx="9"
                    fill="color-mix(in srgb, var(--series-1) ${heat}%, var(--surface-1))" filter="url(#${gid})" class="hm-rect"/>
                  ${mid ? `<text x="${(c.x + 10).toFixed(1)}" y="${(c.y + 20).toFixed(1)}" font-size="${big ? 12.5 : 10.5}" font-weight="700" fill="${dark ? '#fff' : 'var(--text-primary)'}">${esc(s.label.length > c.w / 7 ? s.label.slice(0, Math.max(3, Math.floor(c.w / 7) - 1)) + '…' : s.label)}</text>` : ''}
                  ${big ? `<text x="${(c.x + 10).toFixed(1)}" y="${(c.y + 36).toFixed(1)}" font-size="10.5" fill="${dark ? 'rgba(255,255,255,0.85)' : 'var(--text-secondary)'}">${kfmt(s.views)} · ${share < 1 ? share.toFixed(1) : Math.round(share)}%</text>` : ''}
                  ${big && s.pages > 1 ? `<text x="${(c.x + 10).toFixed(1)}" y="${(c.y + 50).toFixed(1)}" font-size="9.5" fill="${dark ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)'}">${fmtInt(s.pages)} pages${!prefix || !s.leaf ? ' · click to drill' : ''}</text>` : ''}
                  <title>${esc(s.label)} — ${fmtInt(s.views)} views (${share.toFixed(1)}%) · ${fmtInt(s.pages)} page${s.pages > 1 ? 's' : ''}${s.other ? '' : s.pages > 1 && !s.leaf ? ' · click to drill in' : ' · click for page detail'}</title>
                </g>`;
              }).join('')}
            </svg>
            <div class="note" style="margin-top:6px">${prefix ? `Inside <b>${esc(prefix)}</b> — every sub-page sized &amp; heat-coloured by views. Click a block for its pages, or ← back.` : 'Every section of the site sized &amp; heat-coloured by page views (GA4). Click a section to drill into its sub-pages.'}</div>
            ${aiCardHtml('heatmap', 'AI summary — what this map says about your audience')}`;
          el.querySelector('#hm-back')?.addEventListener('click', () => { state.hmPrefix = null; draw(); });
          el.querySelectorAll('.hm-g').forEach((g) =>
            g.addEventListener('click', () => {
              const s = cells[Number(g.dataset.hi)].item;
              if (s.other) { openListModal(siteId, 'pages', prefix || ''); return; }
              if (!prefix) {
                if (s.key === '/' || s.pages <= 1) openListModal(siteId, 'pages', s.key === '/' ? '' : s.key);
                else { state.hmPrefix = s.key; draw(); }
              } else if (s.leaf) {
                const rid = /(\d{3,})\/?$/.exec(s.key)?.[1];
                if (rid) renderLegacyReleaseModal(siteId, rid);
                else openListModal(siteId, 'pages', s.key);
              } else { state.hmPrefix = s.key; draw(); }
            })
          );
          bindAiCard(el, siteId);
        };
        draw();
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── Navigation journeys — how visitors move through the site ──────────
  function renderFlowsCard(siteId) {
    const el = document.getElementById('flows-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/flows?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        if (!d.internal?.length && !d.entries?.length) { el.style.display = 'none'; return; }
        el.style.display = '';
        const maxI = Math.max(1, ...(d.internal || []).map((x) => x.views));
        const maxE = Math.max(1, ...(d.entries || []).map((x) => x.views));
        el.innerHTML = `
          <h3>Visitor journeys — most-travelled paths</h3>
          ${(d.internal || []).slice(0, 8).map((x) => `
            <div class="bar-row wide" title="${fmtInt(x.views)} moves from ${esc(x.from)} to ${esc(x.to)}">
              <div class="bar-label">${esc(x.from)} <span style="color:var(--series-1);font-weight:700">→</span> ${esc(x.to)}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * x.views) / maxI}%"></div></div>
              <div class="bar-value">${fmtInt(x.views)}</div>
            </div>`).join('')}
          ${(d.entries || []).length ? `<h3 style="margin-top:12px">Top entry points — first page by source</h3>
          ${(d.entries || []).slice(0, 6).map((x) => `
            <div class="bar-row wide" title="${fmtInt(x.views)} arrivals from ${esc(x.source)} landing on ${esc(x.to)}">
              <div class="bar-label">${esc(x.source)} <span style="color:var(--series-2);font-weight:700">↘</span> ${esc(x.to)}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * x.views) / maxE}%;background:var(--series-2)"></div></div>
              <div class="bar-value">${fmtInt(x.views)}</div>
            </div>`).join('')}` : ''}
          <div class="note" style="margin-top:6px">Built from GA4's previous-page data: which page visitors were on before each view — the closest thing to a click-path without extra tracking scripts.</div>`;
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── Outbound clicks — where people go when they LEAVE, and at what stage ──
  function renderOutboundCard(siteId) {
    const el = document.getElementById('outbound-card');
    if (!el) return;
    Promise.all([
      api(`/api/site/${encodeURIComponent(siteId)}/outbound?range=${encodeURIComponent(state.range)}`),
      api(`/api/site/${encodeURIComponent(siteId)}/outbound-journeys?range=${encodeURIComponent(state.range)}`).catch(() => null),
    ])
      .then(([d, j]) => {
        el.style.display = '';
        if (!d.collected || !d.domains?.length) {
          el.innerHTML = `<h3>Where visitors go when they leave</h3>
            <div class="note">No outbound-click data collected yet. In GA4, turn on <b>Admin → Data streams → Enhanced measurement → Outbound clicks</b> — destinations (including your consumer site) will appear here automatically.</div>`;
          return;
        }
        const brand = ((state.config?.sites || []).find((x) => x.id === siteId)?.name || '').toLowerCase().split(' ')[0];
        const maxC = Math.max(1, d.domains[0].clicks);
        const KIND_ICON = { 'Press release': '📰', 'Photo gallery': '🖼', 'Video gallery': '🎬', 'Model page': '🚗', 'Homepage': '⌂', 'Search': '🔍', 'Downloads & documents': '📄', 'Executives': '👤', 'Contacts': '☎', 'Registration & alerts': '🔔', 'Other pages': '·' };
        const pageLabel = (p) => p.title || (p.page === '/' ? 'Homepage' : p.page);
        let jHtml = '';
        if (j?.collected) {
          const maxK = Math.max(1, ...(j.byKind || []).map((k) => k.clicks));
          jHtml = `
            <h3 style="margin-top:16px">At what stage do they leave?</h3>
            <div class="note" style="margin-bottom:6px">What visitors were doing at the moment they clicked away${j.ownShare ? ` — <b>${j.ownShare}%</b> of all outbound clicks go to the consumer site` : ''}</div>
            ${(j.byKind || []).slice(0, 8).map((k) => `
              <div class="bar-row wide clickable" data-obkind="${esc(k.kind)}" title="${fmtInt(k.clicks)} outbound clicks from ${esc(k.kind)} pages (${fmtInt(k.ownClicks)} to the consumer site) — click for the exact pages">
                <div class="bar-label">${KIND_ICON[k.kind] || ''} ${esc(k.kind)} <span class="note">· ${k.share}%</span></div>
                <div class="bar-track"><div class="bar-fill" style="width:${(100 * k.clicks) / maxK}%"></div></div>
                <div class="bar-value">${fmtInt(k.clicks)}</div>
              </div>`).join('')}
            ${j.ownByPage?.length ? `
              <h3 style="margin-top:16px">Handovers to the consumer site — from which pages</h3>
              ${j.ownByPage.slice(0, 8).map((p, pi) => `
                <div class="bar-row wide clickable" data-obpage="${pi}" title="${esc(pageLabel(p))} sent ${fmtInt(p.clicks)} people to the consumer site — click for the full journey">
                  <div class="bar-label">${KIND_ICON[p.kind] || ''} ${esc(pageLabel(p))}</div>
                  <div class="bar-track"><div class="bar-fill" style="width:${(100 * p.clicks) / Math.max(1, j.ownByPage[0].clicks)}%"></div></div>
                  <div class="bar-value">${fmtInt(p.clicks)}</div>
                </div>`).join('')}` : ''}`;
        }
        el.innerHTML = `
          <h3>Where visitors go when they leave</h3>
          ${d.domains.slice(0, 10).map((x, di) => {
            const own = brand && brand.length > 2 && x.domain.toLowerCase().includes(brand);
            return `<div class="bar-row wide clickable" data-obdom="${esc(x.domain)}" title="${fmtInt(x.clicks)} outbound clicks to ${esc(x.domain)} by ${fmtInt(x.users)} people — click for the exact links">
              <div class="bar-label">${esc(x.domain)}${own ? ' <span class="delta up">→ consumer site</span>' : ''}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * x.clicks) / maxC}%${own ? '' : ';background:var(--series-2)'}"></div></div>
              <div class="bar-value">${fmtInt(x.clicks)}</div>
            </div>`;
          }).join('')}
          ${jHtml}
          ${aiCardHtml('outbound', 'AI summary — when and why visitors hand over to the consumer site')}
          <div class="note" style="margin-top:8px">How this works: GA4's outbound-click event fires <i>on</i> the page, carrying both the destination link and the page it was clicked from — so every departure is tied to the exact release, gallery or model page the visitor was reading, plus how they arrived there. GA4 is the only connected source that can see this: the platform database records on-platform actions and email tracks in-email clicks, but only the on-page tag witnesses a visitor leaving the website.</div>`;
        // Drill: page kind → the exact exit pages of that kind
        if (j?.collected) {
          el.querySelectorAll('[data-obkind]').forEach((row) =>
            row.addEventListener('click', () => {
              const kind = row.dataset.obkind;
              const pages = (j.byPage || []).filter((p) => p.kind === kind);
              const maxP = Math.max(1, ...pages.map((p) => p.clicks));
              openModal(`<h3>${esc(kind)} — pages visitors left from</h3>
                ${pages.slice(0, 40).map((p) => `
                  <div class="bar-row wide" title="${esc(p.domains.map((d2) => `${d2.domain}: ${fmtInt(d2.clicks)}`).join(' · '))}">
                    <div class="bar-label">${esc(pageLabel(p))}<div class="note" style="font-size:11px">→ ${esc(p.domains.slice(0, 3).map((d2) => d2.domain).join(', '))}</div></div>
                    <div class="bar-track"><div class="bar-fill" style="width:${(100 * p.clicks) / maxP}%"></div></div>
                    <div class="bar-value">${fmtInt(p.clicks)}</div>
                  </div>`).join('')}
                <div class="note" style="margin-top:8px">Each row is a page of this type; the small line shows where its visitors went.</div>`);
            })
          );
          // Drill: consumer-site handover page → full 3-step journey
          el.querySelectorAll('[data-obpage]').forEach((row) =>
            row.addEventListener('click', () => {
              const p = j.ownByPage[Number(row.dataset.obpage)];
              if (!p) return;
              openModal(`<h3>Journey → consumer site</h3>
                <div class="note" style="margin-bottom:10px">${fmtInt(p.clicks)} visitors clicked through to the consumer site from this page in the period.</div>
                ${p.cameFrom?.length ? `<h3 style="font-size:14px">1 · How they arrived here</h3>
                  ${p.cameFrom.map((c2) => `<div class="bar-row wide"><div class="bar-label">${esc(c2.from === '/' ? 'Homepage' : c2.from)}</div>
                    <div class="bar-track"><div class="bar-fill" style="width:${(100 * c2.views) / Math.max(1, p.cameFrom[0].views)}%;background:var(--series-2)"></div></div>
                    <div class="bar-value">${fmtInt(c2.views)}</div></div>`).join('')}` : '<div class="note">GA4 recorded no internal previous-page data for this page in the period.</div>'}
                <h3 style="font-size:14px;margin-top:12px">2 · What they were reading</h3>
                <div class="bar-row wide"><div class="bar-label">${esc(pageLabel(p))} <span class="note">· ${esc(p.kind)}</span></div><div></div><div></div></div>
                <h3 style="font-size:14px;margin-top:12px">3 · Where they went</h3>
                ${(p.domains || []).map((d2) => `<div class="bar-row wide"><div class="bar-label">${esc(d2.domain)} <span class="delta up">consumer site</span></div>
                  <div class="bar-track"><div class="bar-fill" style="width:${(100 * d2.clicks) / Math.max(1, p.domains[0].clicks)}%"></div></div>
                  <div class="bar-value">${fmtInt(d2.clicks)}</div></div>`).join('')}`);
            })
          );
        }
        bindAiCard(el, siteId);
        el.querySelectorAll('[data-obdom]').forEach((row) =>
          row.addEventListener('click', () => {
            const dom = row.dataset.obdom;
            const urls = (d.urls || []).filter((u) => {
              try { return new URL(u.url).hostname.replace(/^www\./, '') === dom.replace(/^www\./, ''); }
              catch { return String(u.url || '').includes(dom); }
            });
            const maxU = Math.max(1, ...urls.map((u) => u.clicks || 0));
            openModal(`<h3>Outbound clicks — <span class="crumb">${esc(dom)}</span></h3>
              ${urls.length ? urls.slice(0, 60).map((u) => `
                <div class="bar-row wide" title="${fmtInt(u.clicks)} clicks · ${fmtInt(u.users)} people">
                  <div class="bar-label"><a href="${esc(u.url)}" target="_blank" rel="noopener" style="color:inherit">${esc(u.url.replace(/^https?:\/\//, '').slice(0, 90))}</a></div>
                  <div class="bar-track"><div class="bar-fill" style="width:${(100 * (u.clicks || 0)) / maxU}%"></div></div>
                  <div class="bar-value">${fmtInt(u.clicks)}</div>
                </div>`).join('') : '<div class="note">GA4 only reported the domain for these clicks, not the full URLs.</div>'}
              <div class="note" style="margin-top:8px">The exact destination links visitors clicked to leave for ${esc(dom)} — each opens in a new tab.</div>`);
          })
        );
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── Uploads & publishing volume — this year vs last year ──────────────
  function renderUploadsCard(siteId) {
    const el = document.getElementById('uploads-card');
    if (!el) return;
    const siteCfg = (state.config?.sites || []).find((x) => x.id === siteId);
    if (!siteCfg?.kind) { el.style.display = 'none'; return; }
    api(`/api/site/${encodeURIComponent(siteId)}/upload-stats?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        const s = d.stats;
        if (!s || !s.months?.length) { el.style.display = 'none'; return; }
        el.style.display = '';
        state.upSel = state.upSel || 'releases';
        const render = () => {
          const key = state.upSel; // 'releases' | 'media'
          const cur = (m) => key === 'releases' ? m.releases : m.media;
          const prv = (m) => key === 'releases' ? m.prevReleases : m.prevMedia;
          const max = Math.max(1, ...s.months.flatMap((m) => [cur(m), prv(m)]));
          const t = s.totals;
          const curT = key === 'releases' ? t.releases : t.media;
          const prvT = key === 'releases' ? t.prevReleases : t.prevMedia;
          const dd = prvT ? Math.round(((curT - prvT) / prvT) * 100) : null;
          el.innerHTML = `
            <div class="card-head">
              <h3>Publishing volume — this year vs last year</h3>
              <div class="metric-toggle">
                <button data-up="releases" class="${key === 'releases' ? 'active' : ''}">Releases</button>
                <button data-up="media" class="${key === 'media' ? 'active' : ''}">Media uploads</button>
              </div>
            </div>
            <div class="facts-row" style="margin-bottom:8px">
              <span class="fact-pill">📦 <b>${fmtInt(curT)}</b> ${key === 'releases' ? 'releases published' : 'media files uploaded'}</span>
              <span class="fact-pill">🕰 <b>${fmtInt(prvT)}</b> same period last year${dd != null ? ` <span class="delta ${dd > 0 ? 'up' : dd < 0 ? 'down' : 'flat'}">${dd > 0 ? '↑' : dd < 0 ? '↓' : '·'}${Math.abs(dd)}%</span>` : ''}</span>
              ${key === 'media' && t.byType ? `
                <span class="fact-pill">🖼 <b>${fmtInt(t.byType.images)}</b> images</span>
                <span class="fact-pill">🎬 <b>${fmtInt(t.byType.videos)}</b> videos</span>
                <span class="fact-pill">📄 <b>${fmtInt(t.byType.documents)}</b> documents</span>` : ''}
            </div>
            ${s.months.map((m) => `
              <div class="bar-row" title="${esc(m.month)}: ${fmtInt(cur(m))} this year vs ${fmtInt(prv(m))} last year${key === 'media' && t.byType ? ` (${fmtInt(m.images || 0)} images · ${fmtInt(m.videos || 0)} videos · ${fmtInt(m.documents || 0)} documents)` : ''}">
                <div class="bar-label">${esc(m.month)}</div>
                <div style="display:flex;flex-direction:column;gap:2px">
                  <div class="bar-track" style="height:8px"><div class="bar-fill" style="width:${(100 * cur(m)) / max}%"></div></div>
                  <div class="bar-track" style="height:8px"><div class="bar-fill" style="width:${(100 * prv(m)) / max}%;background:var(--text-muted);opacity:0.55"></div></div>
                </div>
                <div class="bar-value">${fmtInt(cur(m))} <span class="note">/ ${fmtInt(prv(m))}</span></div>
              </div>`).join('')}
            <div class="note" style="margin-top:6px"><span class="chip-dot c0"></span> this year · <span class="chip-dot" style="background:var(--text-muted)"></span> same months last year — counted straight from the platform database, no GA needed.</div>
            ${!curT && key === 'releases' && s.lastPublished ? `<div class="note" style="margin-top:6px;color:var(--warn-text)">⚠ 0 releases in this window, but the newest release in the connected database is dated <b>${esc(s.lastPublished)}</b>. If the live site has newer content, the DB copy is behind — re-sync it and this card fills in.</div>` : ''}
            ${!s.mediaDateColumn && key === 'media' && !curT ? `<div class="note" style="margin-top:6px;color:var(--warn-text)">⚠ This media table has no upload-date column I recognise, so monthly upload counts can't be computed.</div>` : ''}`;
          el.querySelectorAll('[data-up]').forEach((b) =>
            b.addEventListener('click', () => { state.upSel = b.dataset.up; render(); })
          );
        };
        render();
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── New vs returning users (donut) ────────────────────────────────────
  function renderNewVsReturning(data) {
    const el = document.getElementById('nvr-card');
    if (!el) return;
    const t = data.totals || {};
    const nu = t.newUsers || 0;
    const ret = Math.max(0, (t.totalUsers || 0) - nu);
    const tot = nu + ret;
    if (!tot) { el.style.display = 'none'; return; }
    const pct = (v) => ((100 * v) / tot).toFixed(1) + '%';
    const R = 52, C = 2 * Math.PI * R;
    const newFrac = nu / tot;
    el.innerHTML = `
      <h3>New vs returning</h3>
      <div style="display:flex;align-items:center;gap:18px;margin-top:6px">
        <svg viewBox="0 0 140 140" width="120" height="120" style="flex-shrink:0">
          <circle cx="70" cy="70" r="${R}" fill="none" stroke="var(--series-2)" stroke-width="20"/>
          <circle cx="70" cy="70" r="${R}" fill="none" stroke="var(--series-1)" stroke-width="20"
            stroke-dasharray="${(C * newFrac).toFixed(1)} ${C.toFixed(1)}" transform="rotate(-90 70 70)" stroke-linecap="butt"/>
          <text x="70" y="66" text-anchor="middle" font-size="17" font-weight="700" fill="var(--text-primary)">${pct(nu)}</text>
          <text x="70" y="82" text-anchor="middle" font-size="10" fill="var(--text-muted)">new</text>
        </svg>
        <div style="min-width:0">
          <div style="display:flex;align-items:center;gap:8px;white-space:nowrap;padding:3px 0"><span class="chip-dot c0"></span><span style="font-size:12.5px;color:var(--text-secondary)">New users</span><b style="font-size:13px;margin-left:6px">${fmtInt(nu)} · ${pct(nu)}</b></div>
          <div style="display:flex;align-items:center;gap:8px;white-space:nowrap;padding:3px 0"><span class="chip-dot c1"></span><span style="font-size:12.5px;color:var(--text-secondary)">Returning</span><b style="font-size:13px;margin-left:6px">${fmtInt(ret)} · ${pct(ret)}</b></div>
        </div>
      </div>
      <div class="note" style="margin-top:6px">Share of this period's users seeing the site for the first time</div>`;
  }

  // ── Top day of week by active users ───────────────────────────────────
  function renderDayOfWeek(trend) {
    const el = document.getElementById('dow-card');
    if (!el) return;
    if (!trend?.length || trend.length < 7) { el.style.display = 'none'; return; }
    const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
    const agg = DAYS.map(() => ({ users: 0, sessions: 0, n: 0 }));
    trend.forEach((r) => {
      const dow = (new Date(r.date + 'T00:00:00Z').getUTCDay() + 6) % 7; // Mon = 0
      agg[dow].users += r.totalUsers || 0;
      agg[dow].sessions += r.sessions || 0;
      agg[dow].n++;
    });
    const rows = DAYS.map((day, i) => ({
      day,
      avgUsers: agg[i].n ? Math.round(agg[i].users / agg[i].n) : 0,
      avgSessions: agg[i].n ? Math.round(agg[i].sessions / agg[i].n) : 0,
    })).sort((a, b) => b.avgUsers - a.avgUsers);
    const max = Math.max(1, rows[0].avgUsers);
    el.innerHTML = `
      <h3>Top days of the week — active users</h3>
      ${rows.map((r, i) => `
        <div class="bar-row" title="${esc(r.day)}: avg ${fmtInt(r.avgUsers)} users · ${fmtInt(r.avgSessions)} sessions per ${esc(r.day)}">
          <div class="bar-label">${i === 0 ? '🏆 ' : ''}${esc(r.day)}</div>
          <div class="bar-track"><div class="bar-fill" style="width:${(100 * r.avgUsers) / max}%"></div></div>
          <div class="bar-value">${fmtInt(r.avgUsers)}</div>
        </div>`).join('')}
      <div class="note" style="margin-top:6px">Average active users per weekday this period — the best day to publish &amp; distribute</div>`;
  }

  // ── On-site search terms (GA4 searchTerm) ─────────────────────────────
  function renderSearchesCard(siteId) {
    const el = document.getElementById('searches-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/searches?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        const rows = d.rows || [];
        if (!rows.length) { el.style.display = 'none'; return; }
        el.style.display = '';
        const max = Math.max(1, rows[0].searches || 0);
        el.innerHTML = `
          <div class="card-head"><h3>Top on-site searches</h3>${listControls('searches')}</div>
          ${rows.map((r) => `
            <div class="bar-row" title="'${esc(r.term)}': ${fmtInt(r.searches)} searches by ${fmtInt(r.users)} people">
              <div class="bar-label">🔍 ${esc(r.term)}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r.searches || 0)) / max}%"></div></div>
              <div class="bar-value">${fmtInt(r.searches)}</div>
            </div>`).join('')}
          <div class="note" style="margin-top:6px">What visitors typed into this site's search box — content they want but may not be finding</div>`;
        bindListControls(el, siteId);
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── Channel mix — sessions per GA4 channel group vs the previous period,
  // with Organic Video pinned (clients specifically monitor it) ───────────
  function renderChannelMixCard(siteId) {
    const el = document.getElementById('chmix-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/channel-mix?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        const rows = d.channels || [];
        if (!rows.length) { el.style.display = 'none'; return; }
        el.style.display = '';
        const delta = (cur, prev) => {
          if (!prev) return cur ? ' <span class="delta up">new</span>' : '';
          const p = Math.round((100 * (cur - prev)) / prev);
          if (!p) return ' <span class="delta flat">·0%</span>';
          return ` <span class="delta ${p > 0 ? 'up' : 'down'}">${p > 0 ? '↑' : '↓'}${Math.abs(p)}%</span>`;
        };
        const ICONC = { 'Organic Search': '🔎', 'Organic Video': '🎬', 'Organic Social': '📣', Direct: '➡️', Referral: '🔗', Email: '✉️', Paid: '💰', Display: '🖼' };
        const max = Math.max(1, ...rows.map((r) => r.sessions || 0));
        el.innerHTML = `
          <h3>Channel mix — vs previous period</h3>
          ${rows.map((r) => {
            const ov = r.channel === 'Organic Video';
            return `<div class="bar-row clickable" data-chn="${esc(r.channel)}" title="${esc(r.channel)}: ${fmtInt(r.sessions)} sessions (previous period ${fmtInt(r.prevSessions)}) — click for its sources & landing pages"${ov ? ' style="font-weight:650"' : ''}>
              <div class="bar-label">${ICONC[r.channel] || '📊'} ${esc(r.channel)}${delta(r.sessions, r.prevSessions)}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r.sessions || 0)) / max}%"></div></div>
              <div class="bar-value">${fmtInt(r.sessions)}</div>
            </div>`; }).join('')}
          <div class="note" style="margin-top:6px">Sessions per GA4 channel group. Organic Video (YouTube &amp; co) is always listed — even at zero — so its movement is trackable month to month.</div>`;
        el.querySelectorAll('[data-chn]').forEach((row) =>
          row.addEventListener('click', async () => {
            openModal(`<h3>Channel — ${esc(row.dataset.chn)}</h3><div class="note">Loading…</div>`);
            try {
              const dd = await api(`/api/site/${encodeURIComponent(siteId)}/channel?channel=${encodeURIComponent(row.dataset.chn)}&range=${encodeURIComponent(state.range)}`);
              openModal(`<h3>Channel — <span class="crumb">${esc(row.dataset.chn)}</span></h3>
                <h3 style="margin-top:8px">Sources</h3>
                ${dd.sources?.length ? barList(dd.sources, 'sessions') : '<div class="note">No source data.</div>'}
                <h3 style="margin-top:10px">Landing pages</h3>
                ${dd.landing?.length ? barList(dd.landing, 'sessions') : '<div class="note">No landing-page data.</div>'}`);
            } catch (err) { openModal(`<h3>Channel — ${esc(row.dataset.chn)}</h3><div class="err">${esc(err.message)}</div>`); }
          })
        );
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── "Misc pages" + "Top executives" — the old manual-report charts ─────
  function renderMiscPagesCard(siteId) {
    const el = document.getElementById('miscpages-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/misc-pages?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        state.miscPages = state.miscPages || {};
        state.miscPages[siteId] = d;
        if (!(d.sections || []).length && !(d.executives || []).length) { el.style.display = 'none'; return; }
        el.style.display = '';
        drawMiscPages(siteId);
      })
      .catch(() => { el.style.display = 'none'; });
  }
  function drawMiscPages(siteId) {
    const el = document.getElementById('miscpages-card');
    const d = state.miscPages?.[siteId] || {};
    state.miscSel = state.miscSel || {};
    const sel = state.miscSel[siteId] || 'pages';
    const rows = (sel === 'execs' ? d.executives : d.sections) || [];
    const max = Math.max(1, ...rows.map((r) => r.pageviews || 0));
    el.innerHTML = `
      <div class="card-head"><h3>${sel === 'execs' ? 'Top executives — bio pageviews' : 'Misc pages — pageviews'}</h3>
        ${(d.executives || []).length ? `<div class="metric-toggle">
          <button data-msel="pages" class="${sel === 'pages' ? 'active' : ''}">Pages</button>
          <button data-msel="execs" class="${sel === 'execs' ? 'active' : ''}">Executives</button>
        </div>` : ''}
      </div>
      ${rows.length ? rows.slice(0, 15).map((r) => `
        <div class="bar-row" title="${esc(r.name)}: ${fmtInt(r.pageviews)} pageviews">
          <div class="bar-label">${sel === 'execs' ? '👤' : '📄'} ${esc(r.name)}</div>
          <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r.pageviews || 0)) / max}%"></div></div>
          <div class="bar-value">${fmtInt(r.pageviews)}</div>
        </div>`).join('') : '<div class="note">Nothing in this period.</div>'}
      <div class="note" style="margin-top:6px">${sel === 'execs' ? 'Executive-bio pages ranked by pageviews — the old “Top 15 executives” chart, always current.' : 'Every page outside the release feed — galleries, downloads cart, contacts, technology… (the old “Misc. pages” chart).'}</div>`;
    el.querySelectorAll('[data-msel]').forEach((b) =>
      b.addEventListener('click', () => { state.miscSel[siteId] = b.dataset.msel; drawMiscPages(siteId); }));
  }

  // ── Model interest — dated press engagement per vehicle model ─────────
  function renderModelInterestCard(siteId) {
    const el = document.getElementById('model-interest-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/model-interest?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        const ms = d.models || [];
        if (!ms.length) { el.style.display = 'none'; return; }
        el.style.display = '';
        const max = Math.max(1, ...ms.map((m) => m.pageViews || m.assetDownloads || 0));
        el.innerHTML = `
          <h3>Model interest — what the press engages with</h3>
          ${ms.slice(0, 12).map((m) => {
            const dd = m.prevPageViews ? Math.round(((m.pageViews - m.prevPageViews) / m.prevPageViews) * 100) : null;
            return `<div class="bar-row wide" title="${esc(m.name)}: ${fmtInt(m.pageViews)} model-page views${m.assetDownloads ? ` · ${fmtInt(m.assetDownloads)} asset downloads` : ''}${m.releaseViews ? ` · ${fmtInt(m.releaseViews)} release views` : ''}">
              <div class="bar-label">🚗 ${esc(m.name)}${dd != null ? ` <span class="delta ${dd > 0 ? 'up' : dd < 0 ? 'down' : 'flat'}">${dd > 0 ? '↑' : dd < 0 ? '↓' : '·'}${Math.abs(dd)}%</span>` : ''}</div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * (m.pageViews || m.assetDownloads || 0)) / max}%"></div></div>
              <div class="bar-value">${fmtInt(m.pageViews || m.assetDownloads)}</div>
            </div>`;
          }).join('')}
          <div class="note" style="margin-top:6px">Model-page views this period (Δ vs previous) from the platform event log — hover for asset downloads &amp; release views per model. Every visitor, no consent gap.</div>
          ${aiCardHtml ? '' : ''}`;
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── Email intelligence — clicked links & best send times ─────────────
  function renderEmailIntelCard(siteId) {
    const el = document.getElementById('email-intel-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/email-intel?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        if (!(d.topClickedUrls || []).length && !(d.byHour || []).length) { el.style.display = 'none'; return; }
        el.style.display = '';
        const urls = (d.topClickedUrls || []).slice(0, 8);
        const maxU = Math.max(1, ...urls.map((u) => u.clicks));
        const hours = d.byHour || [];
        const maxH = Math.max(1, ...hours.map((h) => h.opens));
        const bestHour = hours.length ? hours.reduce((a, b) => (b.opens > a.opens ? b : a)) : null;
        const bestDay = (d.byWeekday || []).length ? d.byWeekday.reduce((a, b) => (b.opens > a.opens ? b : a)) : null;
        el.innerHTML = `
          <h3>Email intelligence — what they click &amp; when they read</h3>
          <div class="facts-row" style="margin-bottom:8px">
            ${bestHour ? `<span class="fact-pill">⏰ Peak reading hour: <b>${String(bestHour.hour).padStart(2, '0')}:00</b></span>` : ''}
            ${bestDay ? `<span class="fact-pill">📅 Best day: <b>${esc(bestDay.day)}</b></span>` : ''}
            ${d.avgOpenDelayMinutes != null ? `<span class="fact-pill">⚡ Avg open delay: <b>${d.avgOpenDelayMinutes < 90 ? d.avgOpenDelayMinutes + 'm' : Math.round(d.avgOpenDelayMinutes / 60) + 'h'}</b></span>` : ''}
          </div>
          ${hours.length ? `<div style="display:flex;align-items:flex-end;gap:2px;height:44px;margin:4px 0 10px" title="Opens by hour of day">
            ${hours.map((h) => `<div title="${String(h.hour).padStart(2, '0')}:00 — ${fmtInt(h.opens)} opens · ${fmtInt(h.clicks)} clicks" style="flex:1;background:var(--series-1);opacity:${0.35 + 0.65 * (h.opens / maxH)};height:${Math.max(6, (100 * h.opens) / maxH)}%;border-radius:3px 3px 0 0"></div>`).join('')}
          </div><div class="note" style="margin-top:-6px;margin-bottom:8px">Opens by hour of day (00–23) — send just before the peaks.</div>` : ''}
          ${urls.length ? `<div class="note" style="font-weight:650;color:var(--text-primary);margin-bottom:4px">Most-clicked links in campaigns</div>
            ${urls.map((u) => `<div class="bar-row wide" title="${esc(u.url)} — ${fmtInt(u.clicks)} clicks by ${fmtInt(u.people)} people">
              <div class="bar-label"><a class="ext" href="${esc(u.url)}" target="_blank" rel="noopener" style="visibility:visible;color:inherit;text-align:left">${esc(u.url.replace(/^https?:\/\//, '').slice(0, 70))}</a></div>
              <div class="bar-track"><div class="bar-fill" style="width:${(100 * u.clicks) / maxU}%;background:var(--series-2)"></div></div>
              <div class="bar-value">${fmtInt(u.clicks)}</div>
            </div>`).join('')}` : ''}
          <div class="note" style="margin-top:6px">From the platform's per-recipient email event log — every open and click, timestamped.</div>`;
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── Quietest releases (bottom performers, live ≥ 30 days) ─────────────
  function renderQuietCard(siteId) {
    const el = document.getElementById('quiet-card');
    if (!el) return;
    const siteCfg = (state.config?.sites || []).find((x) => x.id === siteId);
    if (!siteCfg?.kind) { el.style.display = 'none'; return; }
    api(`/api/site/${encodeURIComponent(siteId)}/quiet-releases?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        const rows = d.rows || [];
        if (!rows.length) { el.style.display = 'none'; return; }
        el.style.display = '';
        el.innerHTML = `
          <h3>Quietest releases — bottom ${rows.length} by views</h3>
          <div style="overflow-x:auto"><table class="data">
          <thead><tr><th>Release</th>${rows.some((r) => r.companyName) ? '<th>Client</th>' : ''}<th>Published</th><th class="num">Views</th>${rows.some((r) => r.downloaded != null) ? '<th class="num">Downloads</th>' : ''}</tr></thead>
          <tbody>${rows.map((r) => `
            <tr class="clickable" data-qrid="${esc(String(r.id))}">
              <td class="site-name">${esc(r.title || '#' + r.id)}</td>
              ${rows.some((x) => x.companyName) ? `<td class="note">${esc(r.companyName || '—')}</td>` : ''}
              <td style="white-space:nowrap">${esc(String(r.published || '—').slice(0, 10))}</td>
              <td class="num">${fmtInt(r.viewed)}</td>
              ${rows.some((x) => x.downloaded != null) ? `<td class="num">${fmtInt(r.downloaded)}</td>` : ''}
            </tr>`).join('')}</tbody></table></div>
          <div class="note" style="margin-top:6px">Only releases live for at least a month qualify — candidates for re-promotion, a fresh angle or better distribution. Click one for its 360°.</div>`;
        el.querySelectorAll('[data-qrid]').forEach((row) =>
          row.addEventListener('click', () => renderLegacyReleaseModal(siteId, row.dataset.qrid))
        );
      })
      .catch(() => { el.style.display = 'none'; });
  }

  // ── email campaigns card + send markers on the traffic chart ─────────
  function renderEmailCard(siteId, trendData) {
    const el = document.getElementById('email-card');
    if (!el) return;
    api(`/api/site/${encodeURIComponent(siteId)}/email?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        if (!d.enabled) { el.style.display = 'none'; hideSnap('snap-email'); return; }
        el.style.display = '';
        if (d.totals) setSnap('snap-email', fmtInt(d.totals.sent), `Email reach · ${fmtPct(d.totals.openRate)} opens`);
        if (d.error) {
          el.innerHTML = `<h3>Email campaigns</h3><div class="note">${esc(d.error)}</div>`;
          return;
        }
        // Overlay 📧 markers on the daily traffic chart (keeps the metric toggle)
        if (d.sendDays?.length && trendData) {
          state.siteMarkers = state.siteMarkers || { email: [], peaks: [] };
          state.siteMarkers.email = d.sendDays.map((s) => ({ date: s.date, label: `📧 ${s.date}: ${fmtInt(s.sent)} emails sent` }));
          applySiteMarkers(siteId);
        }
        if (!d.campaigns.length) {
          el.innerHTML = `<h3>Email campaigns — <span class="crumb">${esc(d.domain)}</span></h3>
            <div class="note">No campaigns found in this period.</div>
            ${d.providerErrors?.length ? `<div class="note" style="margin-top:6px;color:var(--bad-text)">⚠ ${esc(d.providerErrors.join(' · '))}</div>` : ''}`;
          return;
        }
        renderEmailTable(el, siteId, d);
      })
      .catch(() => { el.style.display = 'none'; });
  }

  function renderEmailTable(el, siteId, d) {
    const knownRates = d.campaigns.filter((c) => c.openRate != null);
    const avgOpen = knownRates.length ? knownRates.reduce((a, c) => a + c.openRate, 0) / knownRates.length : 0;
    const rowsHtml = (list) => list.map((c) => `
      <tr class="clickable" data-ci="${d.campaigns.indexOf(c)}" title="Click for full campaign performance">
        <td class="site-name">${esc(c.subject || c.name)}${c.subject && c.subject !== c.name ? `<div class="note" style="font-weight:400">${esc(c.name)}</div>` : c.id && c.id !== c.name ? `<div class="note" style="font-weight:400">${esc(c.id)}</div>` : ''}</td>
        <td><span class="prov-badge" title="${c.statsSource ? 'Delivery, opens & clicks verified live from ' + esc(c.statsSource) + '; recipients & per-recipient detail from the reports database' : ''}">${esc(c.provider)}${c.statsSource ? ' + ' + (c.statsSource === 'sparkpost' ? 'SP' : 'BR') : ''}</span></td>
        <td>${c.date ? esc(c.date) : '—'}</td>
        <td class="num">${fmtInt(c.sent)}</td>
        <td class="num"><span class="delta ${c.openRate >= avgOpen * 1.1 ? 'up' : c.openRate <= avgOpen * 0.8 ? 'down' : 'flat'}">${fmtPct(c.openRate)}</span></td>
        <td class="num">${fmtPct(c.clickRate)}</td>
      </tr>`).join('');
    el.innerHTML = `
      <div class="card-head">
        <h3>Email campaigns — <span class="crumb">${esc(d.domain)}</span></h3>
        <input class="card-search" id="email-search" placeholder="Search campaigns…" />
      </div>
      <div class="note" style="margin-bottom:8px">
        ${fmtInt(d.totals.sent)} sent · ${fmtInt(d.totals.opens)} opens (${fmtPct(d.totals.openRate)}) ·
        ${fmtInt(d.totals.clicks)} clicks (${fmtPct(d.totals.clickRate)}) — send days marked 📧 on the traffic chart above
      </div>
      <div style="overflow-x:auto"><table class="data">
        <thead><tr><th>Campaign</th><th>Provider</th><th>Sent</th><th class="num">Recipients</th><th class="num">Open rate</th><th class="num">Click rate</th></tr></thead>
        <tbody id="email-tbody">${rowsHtml(d.campaigns)}</tbody>
      </table></div>
      <div class="note" style="margin-top:6px">Click a campaign to drill into its full performance</div>
      ${d.marketFilter ? `<div class="note" style="margin-top:6px;color:var(--warn-text)">${d.marketFilter.applied
        ? `🌍 Market filter: showing the ${fmtInt(d.marketFilter.matched)} of ${fmtInt(d.marketFilter.total)} campaigns whose title mentions the selected market(s) — email platforms don't tag campaigns by market, so this is a title match.`
        : `🌍 Market filter: no campaign titles mention the selected market(s) — showing all ${fmtInt(d.marketFilter.total)} campaigns unfiltered.`}</div>` : ''}
      ${d.providerErrors?.length ? `<div class="note" style="margin-top:6px;color:var(--bad-text)">⚠ ${esc(d.providerErrors.join(' · '))}</div>` : ''}`;
    const bindRows = () =>
      el.querySelectorAll('[data-ci]').forEach((row) =>
        row.addEventListener('click', () => renderEmailCampaignDetail(el, siteId, d, d.campaigns[Number(row.dataset.ci)]))
      );
    el.querySelector('#email-search').addEventListener('input', (e) => {
      const q = e.target.value.toLowerCase();
      const filtered = d.campaigns.filter((c) =>
        [c.subject, c.name, c.id, c.provider, c.date].filter(Boolean).some((v) => String(v).toLowerCase().includes(q))
      );
      el.querySelector('#email-tbody').innerHTML = filtered.length
        ? rowsHtml(filtered)
        : '<tr><td colspan="6" class="note" style="padding:12px">No campaigns match.</td></tr>';
      bindRows();
    });
    bindRows();
  }

  async function renderEmailCampaignDetail(el, siteId, overview, c) {
    const label = c.subject || c.name;
    const modalBody = openModal(`<h3>Campaign — <span class="crumb">${esc(label)}</span></h3><div class="note">Loading campaign performance…</div>`);
    try {
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/email/campaign?provider=${encodeURIComponent(c.provider)}&cid=${encodeURIComponent(c.id)}&range=${encodeURIComponent(state.range)}`);
      const title = d.subject || d.name || c.subject || c.name;
      let body = `
        <h3>Campaign — <span class="crumb">${esc(title)}</span> <span class="prov-badge">${esc(c.provider)}</span></h3>
        <div class="note" style="margin-bottom:2px">ID: ${esc(c.id)}${d.sentDate || c.date ? ` · sent ${esc(d.sentDate || c.date)}` : ''}</div>
        <div class="note" style="margin:6px 0 8px">
          <b>${fmtInt(c.sent)}</b> recipients · <b>${fmtInt(c.delivered)}</b> delivered ·
          <b>${fmtInt(c.opens)}</b> opens (${fmtPct(c.openRate)}) · <b>${fmtInt(c.clicks)}</b> clicks (${fmtPct(c.clickRate)})
        </div>`;
      if (d.days?.length) {
        body += `<div id="camp-trend"></div>
          <div class="note" style="margin-top:4px">Unique opens per day after the send — the long tail shows how durable the story was</div>`;
      }
      if (d.stats) {
        body += `<div style="margin-top:8px">${[
          ['Soft bounces', d.stats.softBounces], ['Hard bounces', d.stats.hardBounces], ['Unsubscribes', d.stats.unsubscriptions],
        ].map(([l, v]) => `<span class="note" style="margin-right:14px">${l}: <b>${fmtInt(v)}</b></span>`).join('')}</div>`;
      }
      if (d.recipients?.length) {
        body += `<h3 style="margin-top:12px">Recipients (${fmtInt(d.recipients.length)}${d.recipients.length >= 400 ? '+' : ''})</h3>
          <input class="card-search" id="rcpt-search" placeholder="Search email addresses…" style="margin-bottom:8px" />
          <div style="max-height:320px;overflow-y:auto"><table class="data"><thead>
          <tr><th>Email</th><th>Delivered</th><th>Opened</th><th>Clicked</th><th>Unsub</th></tr></thead>
          <tbody id="rcpt-tbody">${d.recipients.map((rc) => `
            <tr><td>${esc(rc.email)}</td>
            <td>${rc.delivered ? '✓' : '<span class="note">—</span>'}</td>
            <td>${rc.opened ? '<span class="delta up">✓</span>' : '<span class="note">—</span>'}</td>
            <td>${rc.clicked ? '<span class="delta up">✓✓</span>' : '<span class="note">—</span>'}</td>
            <td>${rc.unsubscribed ? '<span class="delta down">✗</span>' : '<span class="note">—</span>'}</td></tr>`).join('')}
          </tbody></table></div>`;
      }
      if (d.lists?.length > 1) {
        body += `<h3 style="margin-top:12px">By contact list</h3>
          <table class="data"><thead><tr><th>List</th><th class="num">Sent</th><th class="num">Delivered</th><th class="num">Opens</th><th class="num">Clicks</th></tr></thead>
          <tbody>${d.lists.map((l) => `<tr><td>List ${esc(String(l.listId))}</td><td class="num">${fmtInt(l.sent)}</td><td class="num">${fmtInt(l.delivered)}</td><td class="num">${fmtInt(l.opens)}</td><td class="num">${fmtInt(l.clicks)}</td></tr>`).join('')}</tbody></table>`;
      }
      modalBody.innerHTML = body;
      if (d.days?.length) {
        trendChart(document.getElementById('camp-trend'), d.days.map((r) => ({ date: r.date, opens: r.opens })), 'opens', 'Unique opens', 160);
      }
      const rs = modalBody.querySelector('#rcpt-search');
      if (rs) rs.addEventListener('input', () => {
        const qq = rs.value.toLowerCase();
        modalBody.querySelectorAll('#rcpt-tbody tr').forEach((tr) => {
          tr.style.display = tr.textContent.toLowerCase().includes(qq) ? '' : 'none';
        });
      });
    } catch (err) {
      modalBody.innerHTML = `<h3>Campaign — <span class="crumb">${esc(label)}</span></h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // ── release performance tracker ──────────────────────────────────────
  function renderReleasesCard(siteId) {
    const el = document.getElementById('releases-card');
    if (!el) return;
    // Follows the GLOBAL date range + market selection like every other card.
    const rangeKey = state.range || '28d';
    const rl = (RANGES.find(([k]) => k === rangeKey) || [])[1]
      || (rangeKey.includes('..') ? rangeKey.replace('..', ' → ') : rangeKey);
    const rlText = /^\d+ days$/.test(rl) ? 'last ' + rl : rl;
    el.innerHTML = `<h3>Release performance</h3><div class="note">Analysing the ${esc(rlText)}…</div>`;
    api(`/api/site/${encodeURIComponent(siteId)}/releases?range=${encodeURIComponent(rangeKey)}`)
      .then((d) => {
        if (!d.releasePath || !d.releases.length) {
          el.innerHTML = '<h3>Release performance</h3><div class="note">No release-style section detected on this site yet. You can set "releasePath" for this site in sites.config.json (e.g. "/releases").</div>';
          hideSnap('snap-story');
          return;
        }
        const best = d.releases[0];
        // Humans read titles, not ids — show the release TITLE when the
        // reports DB resolved one, falling back to the path slug.
        const storyLabel = best.title
          ? (String(best.title).length > 46 ? String(best.title).slice(0, 44) + '…' : String(best.title))
          : (best.path.split('/').pop() || best.path);
        setSnap('snap-story', esc(storyLabel), `Top story · ${fmtInt(best.first7)} first-week views${best.index ? ' · ' + best.index.toFixed(1) + '×' : ''}`);
        const storyChip = document.getElementById('snap-story');
        if (storyChip) { storyChip.dataset.path = best.path; storyChip.title = `Click to open the dossier for ${best.path}`; }
        const rowsHtml = (list) => list.map((r, i) => `
          <tr class="clickable" data-path="${esc(r.path)}">
            <td>${i + 1}</td>
            <td class="site-name">${r.title ? esc(r.title) : esc(r.path)}${r.companyName ? ` <span class="prov-badge">${esc(r.companyName)}</span>` : ''} ${siteUrlFor(siteId) ? `<a class="ext" href="${esc(siteUrlFor(siteId) + r.path)}" target="_blank" rel="noopener" title="Open on the live site">↗</a>` : ''}${r.title ? `<div class="note" style="font-weight:400">${esc(r.path)}</div>` : ''}</td>
            <td>${r.preExisting ? '<span class="note">earlier</span>' : esc(r.launch)}</td>
            <td>${r.email
              ? `<span title="${esc(r.email.name)} (${esc(r.email.provider)}${r.email.date ? ', ' + esc(r.email.date) : ''})">📧 ${fmtInt(r.email.sent)} · ${fmtPct(r.email.openRate)}</span>`
              : '<span class="note">—</span>'}</td>
            <td class="num">${fmtInt(r.first7)}</td>
            <td class="num">${r.index != null
              ? `<span class="delta ${r.index >= 1.15 ? 'up' : r.index <= 0.7 ? 'down' : 'flat'}">${r.index.toFixed(1)}×</span>`
              : '—'}</td>
            <td class="num">${fmtInt(r.total)}</td>
          </tr>`).join('');
        el.innerHTML = `
          <div class="card-head">
            <h3>Release performance — <span class="crumb">${esc(d.releasePath)}</span> (${esc(rlText)})</h3>
            <input class="card-search" id="releases-search" placeholder="Search by ID, path, campaign…" />
          </div>
          <div style="overflow-x:auto"><table class="data">
            <thead><tr><th>#</th><th>Release</th><th>Launched</th><th>Emailed</th><th class="num">First 7 days</th><th class="num">vs avg release</th><th class="num">Total views</th></tr></thead>
            <tbody id="releases-tbody">${rowsHtml(d.releases)}</tbody>
          </table></div>
          <div class="note" style="margin-top:8px">Average release earns ${fmtInt(d.avg7)} views in its first 7 days — click a release for its full dossier</div>
          ${isAdminUser() && d.titleSource && d.titleSource !== 'db' ? `
            <div class="note" style="margin-top:6px;color:var(--warn-text)">
              ⚠ Titles unavailable — ${
                d.titleSource === 'no-db'
                  ? `this site has no reports database connected. Open <b>Sites → edit this site</b>, fill in the DB connection, click Test, then <b>Save</b>.`
                  : d.titleSource === 'db-empty'
                    ? 'the reports DB is connected but none of these release ids exist in its releases table — the site is probably pointed at the wrong database/schema.'
                    : esc(d.titleSource)
              }</div>` : ''}`;
        const bindRows = () => {
          el.querySelectorAll('a.ext').forEach((a) => a.addEventListener('click', (e) => e.stopPropagation()));
          el.querySelectorAll('[data-path]').forEach((row) =>
            row.addEventListener('click', () => {
              renderPageDetail(siteId, row.dataset.path);
            })
          );
        };
        el.querySelector('#releases-search').addEventListener('input', (e) => {
          const q = e.target.value.toLowerCase();
          const filtered = d.releases.filter((r) =>
            r.path.toLowerCase().includes(q) ||
            (r.title || '').toLowerCase().includes(q) ||
            (r.companyName || '').toLowerCase().includes(q) ||
            (r.launch || '').includes(q) ||
            (r.email && r.email.name.toLowerCase().includes(q))
          );
          const tbody = el.querySelector('#releases-tbody');
          tbody.innerHTML = filtered.length
            ? rowsHtml(filtered)
            : '<tr><td colspan="7" class="note" style="padding:12px">Not in the top releases — searching all of GA4…</td></tr>';
          bindRows();
          if (!filtered.length && q.trim().length >= 2) {
            ga4Search(siteId, q.trim(), null, (resultsHtml) => {
              tbody.innerHTML = `<tr><td colspan="7" style="padding:8px">${resultsHtml}</td></tr>`;
              tbody.querySelectorAll('.ga4-result').forEach((r) =>
                r.addEventListener('click', () => renderPageDetail(siteId, r.dataset.path))
              );
            });
          }
        });
        bindRows();
      })
      .catch((err) => {
        if (err.message !== 'unauthorised')
          el.innerHTML = `<h3>Release performance</h3><div class="note">Couldn't analyse: ${esc(err.message)}</div>`;
      });
  }

  // ── earned-media radar ───────────────────────────────────────────────
  function renderRadarCard(siteId) {
    const el = document.getElementById('radar-card');
    if (!el) return;
    el.innerHTML = '<h3>Earned media radar</h3><div class="note">Scanning for new referring domains…</div>';
    api(`/api/site/${encodeURIComponent(siteId)}/radar?range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        if (!d.newReferrers.length) {
          el.innerHTML = `<h3>Earned media radar</h3><div class="note">No new referring domains this period — every referral source was already known in the previous ${d.baselineDays} days.</div>`;
          setSnap('snap-radar', '0', 'New coverage sources');
          return;
        }
        setSnap('snap-radar', String(d.newReferrers.length), `New coverage · top: ${esc(d.newReferrers[0].dimensionValue)}`);
        el.innerHTML = `
          <h3>Earned media radar — new referring domains</h3>
          ${barList(d.newReferrers, 'sessions')}
          <div class="note" style="margin-top:8px">Domains that sent traffic this period but none in the previous ${d.baselineDays} days — likely fresh coverage or new links. Worth a look and a thank-you.</div>`;
      })
      .catch((err) => {
        if (err.message !== 'unauthorised')
          el.innerHTML = `<h3>Earned media radar</h3><div class="note">Couldn't scan: ${esc(err.message)}</div>`;
      });
  }

  // ── anomaly alerts banner (overview) ─────────────────────────────────
  function renderAlerts() {
    const el = document.getElementById('alerts-card');
    if (!el) return;
    api('/api/alerts')
      .then((d) => {
        if (!d.alerts.length) { el.innerHTML = ''; return; }
        el.innerHTML = `<div class="card">
          <h3>Recent alerts (last 7 days)</h3>
          <div class="insights">${d.alerts.slice(-6).reverse().map((a) => `
            <div class="insight">
              <span class="ins-ic ${a.kind === 'spike' ? 'up' : 'down'}">${a.kind === 'spike' ? '▲' : '▼'}<span class="ins-lbl">${a.kind}</span></span>
              <div class="ins-body">
                <div class="ins-fact">${esc(a.siteName)} — ${esc(a.date)}: ${fmtInt(a.sessions)} sessions (${a.ratio}× the typical ${fmtInt(a.typical)})${a.topPage ? `, led by ${esc(a.topPage)}` : ''}${a.topSource ? ` via ${esc(a.topSource)}` : ''}</div>
              </div>
            </div>`).join('')}
          </div>
        </div>`;
      })
      .catch(() => { el.innerHTML = ''; });
  }

  // ── user management (admins) ─────────────────────────────────────────
  const ROLE_LABELS = { superadmin: 'Super admin', admin: 'Admin', client: 'Client' };
  async function renderUsers(el) {
    const d = await api('/api/users');
    const me = state.user;
    const pending = d.users.filter((u) => u.status === 'pending');
    const canManage = (u) => me.role === 'superadmin' ? u.id !== me.id : u.role === 'client';
    const siteName = (id) => state.config.sites.find((s) => s.id === id)?.name || id;
    const row = (u) => `
      <tr data-uid="${esc(u.id)}" class="${u.status === 'pending' ? 'row-pending' : ''}">
        <td class="site-name">${esc(u.name)}<div class="note" style="font-weight:400">${esc(u.email)}</div></td>
        <td>
          ${canManage(u) ? `<select class="rep-select role-sel" style="padding:4px 8px">
            ${['client', 'admin', 'superadmin'].map((r) => `<option value="${r}" ${u.role === r ? 'selected' : ''} ${r !== 'client' && me.role !== 'superadmin' ? 'disabled' : ''}>${ROLE_LABELS[r]}</option>`).join('')}
          </select>` : `<span class="prov-badge">${ROLE_LABELS[u.role]}</span>`}
        </td>
        <td><span class="status-badge st-${u.status}">${u.status}</span></td>
        <td>${u.role === 'client'
          ? `<button class="back-link sites-btn" style="margin:0">${u.siteIds.length ? u.siteIds.map(siteName).map(esc).join(', ').slice(0, 40) + (u.siteIds.map(siteName).join(', ').length > 40 ? '…' : '') : 'No sites yet'}</button>`
          : '<span class="note">All sites</span>'}</td>
        <td style="white-space:nowrap">
          ${u.status === 'pending' && canManage(u) ? '<button class="btn-primary approve-btn" style="padding:4px 12px">Approve</button> ' : ''}
          ${canManage(u) && u.status !== 'pending' ? `<button class="back-link toggle-btn" style="margin:0">${u.status === 'active' ? 'Disable' : 'Enable'}</button> ` : ''}
          ${canManage(u) ? '<button class="back-link pw-btn" style="margin:0">Password</button> <button class="back-link del-btn" style="margin:0;color:var(--bad-text)">Delete</button>' : ''}
        </td>
      </tr>`;
    el.innerHTML = `
      <div class="grid" style="gap:16px">
        ${pending.length ? `<div class="card" style="border-color:var(--warn-text)">
          <h3>⚠ ${pending.length} account${pending.length > 1 ? 's' : ''} awaiting approval</h3>
          <div class="note">Approve them below, then assign their sites.</div>
        </div>` : ''}
        <div class="card">
          <div class="card-head">
            <h3>Users</h3>
            <button class="btn-primary" id="invite-btn" style="padding:6px 14px">＋ Add user</button>
          </div>
          <div style="overflow-x:auto"><table class="data">
            <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Sites</th><th>Actions</th></tr></thead>
            <tbody>${d.users.map(row).join('')}</tbody>
          </table></div>
          <div class="note" style="margin-top:8px">Clients see only their assigned sites. Admins see everything except user-role promotion, which is reserved for the super admin.</div>
        </div>
      </div>`;

    const refresh = () => renderUsers(el);
    const act = async (fn) => { try { await fn(); refresh(); } catch (err) { alertNote(err.message); } };
    function alertNote(msg) {
      const b = openModal(`<h3>Couldn't do that</h3><div class="err" style="padding:8px 0">${esc(msg)}</div>`);
      void b;
    }

    el.querySelectorAll('tr[data-uid]').forEach((tr) => {
      const uid = tr.dataset.uid;
      const u = d.users.find((x) => x.id === uid);
      tr.querySelector('.role-sel')?.addEventListener('change', (e) =>
        act(() => api('/api/users/update', { method: 'POST', body: JSON.stringify({ id: uid, role: e.target.value }) })));
      tr.querySelector('.approve-btn')?.addEventListener('click', () =>
        act(() => api('/api/users/update', { method: 'POST', body: JSON.stringify({ id: uid, status: 'active' }) })));
      tr.querySelector('.toggle-btn')?.addEventListener('click', () =>
        act(() => api('/api/users/update', { method: 'POST', body: JSON.stringify({ id: uid, status: u.status === 'active' ? 'disabled' : 'active' }) })));
      tr.querySelector('.del-btn')?.addEventListener('click', (e) => {
        if (e.target.dataset.arm) return act(() => api('/api/users/delete', { method: 'POST', body: JSON.stringify({ id: uid }) }));
        e.target.dataset.arm = '1'; e.target.textContent = 'Really delete?';
        setTimeout(() => { e.target.dataset.arm = ''; e.target.textContent = 'Delete'; }, 2500);
      });
      tr.querySelector('.pw-btn')?.addEventListener('click', () => {
        const body = openModal(`
          <h3>Set a new password for ${esc(u.name)}</h3>
          <form id="pw-form" class="auth-form" style="margin-top:10px">
            <input class="auth-in" name="password" type="text" placeholder="New password (min 8)" required minlength="8" />
            <button class="btn-primary">Save password</button>
          </form>`);
        body.querySelector('#pw-form').addEventListener('submit', (e) => {
          e.preventDefault();
          const pw = new FormData(e.target).get('password');
          closeModal();
          act(() => api('/api/users/update', { method: 'POST', body: JSON.stringify({ id: uid, password: pw }) }));
        });
      });
      tr.querySelector('.sites-btn')?.addEventListener('click', () => {
        const body = openModal(`
          <h3>Sites for ${esc(u.name)}</h3>
          <p class="note">Tick every website this client may see.</p>
          <form id="sites-form" style="margin-top:10px;display:grid;gap:6px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))">
            ${state.config.sites.map((s) => `
              <label class="fact-pill" style="cursor:pointer;display:flex;gap:8px;align-items:center">
                <input type="checkbox" name="site" value="${esc(s.id)}" ${u.siteIds.includes(s.id) ? 'checked' : ''} /> ${esc(s.name)}
              </label>`).join('')}
          </form>
          <button class="btn-primary" id="sites-save" style="margin-top:12px">Save assignment</button>`);
        body.querySelector('#sites-save').addEventListener('click', () => {
          const ids = [...body.querySelectorAll('input[name="site"]:checked')].map((c) => c.value);
          closeModal();
          act(() => api('/api/users/update', { method: 'POST', body: JSON.stringify({ id: uid, siteIds: ids }) }));
        });
      });
    });

    document.getElementById('invite-btn').addEventListener('click', () => {
      const body = openModal(`
        <h3>Add a user directly</h3>
        <p class="note">Created active immediately — no approval step needed.</p>
        <form id="invite-form" class="auth-form" style="margin-top:10px">
          <input class="auth-in" name="name" placeholder="Name" required />
          <input class="auth-in" name="email" type="email" placeholder="Email" required />
          <input class="auth-in" name="password" type="text" placeholder="Temporary password (min 8)" required minlength="8" />
          <select class="rep-select" name="role" id="invite-role">
            <option value="client">Client</option>
            ${me.role === 'superadmin' ? '<option value="admin">Admin</option>' : ''}
          </select>
          <div id="invite-sites">
            <div class="note" style="margin:4px 0 6px">Sites this client can see — tick as many as you like:</div>
            <div style="display:grid;gap:6px;grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
              ${state.config.sites.map((s) => `
                <label class="fact-pill" style="cursor:pointer;display:flex;gap:8px;align-items:center">
                  <input type="checkbox" name="site" value="${esc(s.id)}" /> ${esc(s.name)}
                </label>`).join('')}
            </div>
          </div>
          <button class="btn-primary">Create user</button>
        </form>`);
      const roleSel = body.querySelector('#invite-role');
      const sitesBox = body.querySelector('#invite-sites');
      roleSel.addEventListener('change', () => { sitesBox.style.display = roleSel.value === 'client' ? '' : 'none'; });
      body.querySelector('#invite-form').addEventListener('submit', (e) => {
        e.preventDefault();
        const fd = new FormData(e.target);
        const dta = Object.fromEntries(fd.entries());
        delete dta.site;
        dta.siteIds = dta.role === 'client' ? fd.getAll('site') : [];
        closeModal();
        act(() => api('/api/users/create', { method: 'POST', body: JSON.stringify(dta) }));
      });
    });
  }

  // ── billing (superadmin): Stripe plan, invoices, portal ──────────────
  // Licence status card — shown at the top of the Billing page.
  async function licenseCardHtml() {
    try {
      const { license: l } = await api('/api/license');
      const MODE = { licensed: ['var(--good-text)', '✓ Licensed'], trial: ['var(--warn-text)', '⏳ Free trial'], locked: ['var(--bad-text)', '✕ Locked'] };
      const [col, lbl] = MODE[l.mode] || MODE.trial;
      return `<div class="card">
        <div class="card-head"><h3>SparkSoftware licence</h3>
          <span style="color:${col};font-weight:800">${lbl}</span></div>
        <div style="display:flex;gap:20px;flex-wrap:wrap;font-size:13px;margin-top:4px">
          ${l.licensee ? `<div><span class="note">Licensed to</span><br><b>${esc(l.licensee)}</b></div>` : ''}
          ${l.plan && l.plan !== 'demo' ? `<div><span class="note">Plan</span><br><b>${esc(l.plan)}</b>${l.plan === 'yearly' ? ' <span class="note">(15% saving)</span>' : ''}</div>` : ''}
          ${l.expires ? `<div><span class="note">Valid until</span><br><b>${new Date(l.expires).toISOString().slice(0, 10)}</b> <span class="note">(renews automatically while the subscription is active)</span></div>` : ''}
          ${l.mode === 'trial' ? `<div><span class="note">Trial</span><br><b>${l.daysLeft} day${l.daysLeft === 1 ? '' : 's'} left</b> of ${l.trialDays || 30}</div>` : ''}
        </div>
        ${l.warning ? `<div class="note" style="color:var(--warn-text);margin-top:8px">⚠ ${esc(l.warning)}</div>` : ''}
        <div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
          <a class="mini-btn" href="https://spark-software.de/reporting#pricing" target="_blank" rel="noopener" style="text-decoration:none">${l.mode === 'licensed' ? 'Manage subscription' : 'Get a licence'} ↗</a>
          <button class="mini-btn" id="lic-change">Enter a licence key</button>
        </div>
      </div>`;
    } catch { return ''; }
  }

  async function renderBilling(el) {
    el.innerHTML = '<div class="loading">Checking your subscription with Stripe…</div>';
    const licCard = await licenseCardHtml();
    const wireLic = () => document.getElementById('lic-change')?.addEventListener('click', () => {
      const body = openModal(`
        <h3>Enter a licence key</h3>
        <form id="lic-key-form" class="auth-form" style="margin-top:12px">
          <textarea class="auth-in" name="key" rows="4" placeholder="SPARK.xxxxx.xxxxx" style="font-family:monospace;font-size:12px" required></textarea>
          <button class="btn-primary" type="submit">Activate</button>
          <div class="note" id="lic-key-status"></div>
        </form>`);
      body.querySelector('#lic-key-form').addEventListener('submit', async (ev) => {
        ev.preventDefault();
        const st = body.querySelector('#lic-key-status');
        st.textContent = 'Checking key…';
        try {
          const r = await api('/api/license/activate', { method: 'POST', body: JSON.stringify({ key: new FormData(ev.target).get('key') }) });
          st.innerHTML = `<span style="color:var(--good-text)">✓ Licensed to ${esc(r.license?.licensee || 'you')}</span>`;
          setTimeout(() => { closeModal(); renderBilling(el); }, 1000);
        } catch (err) { st.innerHTML = `<span style="color:var(--bad-text)">${esc(err.message)}</span>`; }
      });
    });
    let d;
    try { d = await api('/api/billing'); }
    catch (err) {
      el.innerHTML = `<div class="grid" style="gap:16px">${licCard}<div class="card"><h3>Billing</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div></div></div>`;
      wireLic();
      return;
    }
    if (!d.configured) {
      el.innerHTML = `${licCard ? `<div class="grid" style="gap:16px">${licCard}</div>` : ''}
        <div class="card">
          <h3>Stripe is not connected yet</h3>
          <div class="note" style="line-height:1.7;margin-top:6px">
            Add these two lines to your <code>.env</code> and restart the app:<br/>
            <code>STRIPE_SECRET_KEY=sk_live_…</code> — from Stripe → Developers → API keys<br/>
            <code>STRIPE_PRICE_ID=price_…</code> — from your Product's recurring price<br/><br/>
            Then this page shows your plan, renewal date and every invoice with a PDF download, and the
            <b>Manage billing</b> button opens Stripe's secure portal for card changes and cancellations.
          </div>
        </div>`;
      wireLic();
      return;
    }
    const p = d.plan;
    const stBadge = (st) => `<span class="status-badge st-${st === 'active' || st === 'trialing' ? 'active' : 'pending'}">${esc(st || 'none')}</span>`;
    el.innerHTML = `${licCard}
      <div class="grid" style="gap:16px">
        <div class="card">
          <div class="card-head"><h3>Your plan</h3>
            <div style="display:flex;gap:8px">
              ${p ? '<button class="btn-primary" id="bill-portal">Manage billing</button>' : '<button class="btn-primary" id="bill-checkout">Subscribe</button>'}
            </div>
          </div>
          ${p ? `
            <div class="facts-row" style="margin-top:6px">
              <span class="fact-pill"><b>${esc(p.productName)}</b></span>
              ${stBadge(p.status)}
              ${p.amount != null ? `<span class="fact-pill">${esc(p.currency)} <b>${p.amount.toFixed(2)}</b> / ${esc(p.interval)}</span>` : ''}
              ${p.currentPeriodEnd ? `<span class="fact-pill">${p.cancelAtPeriodEnd ? 'Ends' : 'Renews'} <b>${esc(p.currentPeriodEnd)}</b></span>` : ''}
            </div>
            <div class="note" style="margin-top:8px">Card changes, plan cancellation and billing details are handled securely by Stripe — click <b>Manage billing</b>.</div>`
          : `<div class="note" style="margin-top:6px">No active subscription. Click <b>Subscribe</b> to check out securely with Stripe.</div>`}
        </div>
        <div class="card">
          <h3>Invoices</h3>
          ${d.invoices?.length ? `<div style="overflow-x:auto"><table class="data">
            <thead><tr><th>Invoice</th><th>Date</th><th class="num">Amount</th><th>Status</th><th>Download</th></tr></thead>
            <tbody>${d.invoices.map((inv) => `
              <tr>
                <td class="site-name">${esc(inv.number)}</td>
                <td>${esc(inv.date)}</td>
                <td class="num">${esc(inv.currency)} ${inv.amount.toFixed(2)}</td>
                <td><span class="status-badge st-${inv.status === 'paid' ? 'active' : 'pending'}">${esc(inv.status)}</span></td>
                <td>${inv.pdf ? `<a class="ext" href="${esc(inv.pdf)}" target="_blank" rel="noopener">PDF ↓</a>` : ''}${inv.hosted ? ` <a class="ext" href="${esc(inv.hosted)}" target="_blank" rel="noopener">View</a>` : ''}</td>
              </tr>`).join('')}</tbody></table></div>`
          : '<div class="note">No invoices yet — they appear here automatically after your first payment, straight from Stripe.</div>'}
        </div>
      </div>`;
    wireLic();
    const go = async (path) => {
      try { const r2 = await api(path, { method: 'POST' }); location.href = r2.url; }
      catch (err) { openModal(`<h3>Stripe</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`); }
    };
    document.getElementById('bill-checkout')?.addEventListener('click', () => go('/api/billing/checkout'));
    document.getElementById('bill-portal')?.addEventListener('click', () => go('/api/billing/portal'));
  }

  // ── site configuration (admins) ──────────────────────────────────────
  async function renderSitesAdmin(el) {
    const cfg = await api('/api/sites-config');
    const sites = cfg.sites || [];
    el.innerHTML = `
      <div class="grid" style="gap:16px">
        <div class="card">
          <div class="card-head">
            <h3>Websites (${sites.length})</h3>
            <div style="display:flex;gap:8px">
              <button class="back-link" id="import-central" style="margin:0">⇩ Import from central reports DB</button>
              <button class="btn-primary" id="add-site" style="padding:6px 14px">＋ Add website</button>
            </div>
          </div>
          <div style="overflow-x:auto"><table class="data">
            <thead><tr><th>Site</th><th>GA4 property</th><th>Email domains</th><th>Release path</th><th>Actions</th></tr></thead>
            <tbody>${sites.map((s, i) => `
              <tr data-i="${i}">
                <td class="site-name">${esc(s.name)}<div class="note" style="font-weight:400">${esc(s.id)} · ${esc(s.url || 'no url')}</div></td>
                <td>${s.propertyId === 'demo' ? '<span class="demo-badge">demo</span>' : esc(s.propertyId)}</td>
                <td class="note">${esc([s.email?.sparkpost && 'SP: ' + s.email.sparkpost, s.email?.brevo && 'BR: ' + s.email.brevo].filter(Boolean).join(' · ') || '—')}</td>
                <td class="note">${esc(s.releasePath || 'auto-detect')}</td>
                <td style="white-space:nowrap">
                  <button class="back-link edit-btn" style="margin:0">Edit</button>
                  <button class="back-link del-btn" style="margin:0;color:var(--bad-text)">Delete</button>
                </td>
              </tr>`).join('')}
            </tbody>
          </table></div>
          <div class="note" style="margin-top:8px">Changes apply immediately — no restart, no JSON editing. Remember to add the service account as Viewer on any new GA4 property.</div>
        </div>
      </div>`;

    const save = async (newSites) => {
      try {
        await api('/api/sites-config/save', { method: 'POST', body: JSON.stringify({ sites: newSites }) });
        state.config = await api('/api/config'); // sidebar refresh
        renderShell();
      } catch (err) {
        openModal(`<h3>Couldn't save</h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`);
      }
    };

    const siteForm = (s = {}, onSave) => {
      const body = openModal(`
        <h3>${s.id ? 'Edit — ' + esc(s.name) : 'Add a website'}</h3>
        <form id="site-form" class="auth-form" style="margin-top:12px">
          <label class="note">Display name</label>
          <input class="auth-in" name="name" value="${esc(s.name || '')}" placeholder="Client A Press Office" required />
          <label class="note">Internal id (lowercase-with-hyphens; leave blank to auto-generate)</label>
          <input class="auth-in" name="id" value="${esc(s.id || '')}" placeholder="client-a" pattern="[a-z0-9][a-z0-9-]*" />
          <label class="note">Website URL</label>
          <input class="auth-in" name="url" value="${esc(s.url || '')}" placeholder="https://press.client-a.co.uk" />
          <label class="note">GA4 Property ID (numeric, "demo" — or leave EMPTY for a media-monitoring-only client)</label>
          <input class="auth-in" name="propertyId" value="${esc(s.propertyId === 'none' ? '' : s.propertyId || '')}" placeholder="421337555 — empty = monitoring only" />
          <label class="note">Release section path (optional — auto-detected when blank)</label>
          <input class="auth-in" name="releasePath" value="${esc(s.releasePath || '')}" placeholder="/releases" />
          <label class="note">SparkPost sending domain (optional)</label>
          <input class="auth-in" name="sparkpost" value="${esc(s.email?.sparkpost || '')}" placeholder="spmail.client-a.co.uk" />
          <label class="note">Brevo sender domain (optional)</label>
          <input class="auth-in" name="brevo" value="${esc(s.email?.brevo || '')}" placeholder="mail.client-a.co.uk" />
          <label class="note"><b>Client identity (CI)</b> — colours, font &amp; logo. Applied to this client's dashboard, PDF reports and PowerPoint decks.</label>
          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
            <div style="display:flex;gap:6px;align-items:center">
              <input type="color" id="cip-pick" value="${/^#[0-9a-fA-F]{6}$/.test(s.ci?.primary || '') ? esc(s.ci.primary) : '#131312'}" style="width:38px;height:34px;padding:2px;border:1px solid var(--border);border-radius:8px;background:var(--surface-1);cursor:pointer" title="Primary colour" />
              <input class="auth-in" name="ciprimary" value="${esc(s.ci?.primary || '')}" placeholder="#131312 — primary (chrome, title slides)" style="flex:1" />
            </div>
            <div style="display:flex;gap:6px;align-items:center">
              <input type="color" id="cia-pick" value="${/^#[0-9a-fA-F]{6}$/.test(s.ci?.accent || '') ? esc(s.ci.accent) : '#EB6834'}" style="width:38px;height:34px;padding:2px;border:1px solid var(--border);border-radius:8px;background:var(--surface-1);cursor:pointer" title="Accent colour" />
              <input class="auth-in" name="ciaccent" value="${esc(s.ci?.accent || '')}" placeholder="#EB6834 — accent (charts & bars)" style="flex:1" />
            </div>
          </div>
          <input class="auth-in" name="cifont" value="${esc(s.ci?.font || '')}" placeholder="Font — any Google Font name, e.g. Montserrat (empty = Inter)" />
          <div style="display:flex;gap:8px;align-items:center">
            <input class="auth-in" name="cilogo" value="${esc(s.ci?.logo || '')}" placeholder="Logo — https URL to a PNG/JPG, or upload →" style="flex:1" />
            <label class="mini-btn" style="cursor:pointer;white-space:nowrap">⬆ Upload<input type="file" id="ci-logo-file" accept="image/png,image/jpeg" style="display:none" /></label>
          </div>
          <div class="note" id="ci-logo-note" style="margin-top:-4px">${s.ci?.logo ? (String(s.ci.logo).startsWith('data:') ? '✓ Logo uploaded (stored with the site)' : '') : 'The logo appears on the client dashboard, report headers and every deck slide.'}</div>
          <div style="border-top:1px dashed var(--grid);margin:6px 0 2px"></div>
          <label class="note"><b>Reports database</b> (optional — AWS RDS / MySQL, read-only user)</label>
          <select class="rep-select" name="dbkind">
            <option value="">No database (auto-detects type on save if host is filled)</option>
            <option value="newsroom" ${s.db?.kind === 'newsroom' ? 'selected' : ''}>Newsroom (one client per DB)</option>
            <option value="newspress" ${s.db?.kind === 'newspress' ? 'selected' : ''}>Newspress market (many clients per DB)</option>
          </select>
          <div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
            <input class="auth-in" name="dbhost" value="${esc(s.db?.host || '')}" placeholder="xxxx.eu-west-2.rds.amazonaws.com" />
            <input class="auth-in" name="dbport" value="${esc(s.db?.port || '3306')}" placeholder="3306" />
          </div>
          <input class="auth-in" name="dbname" value="${esc(s.db?.database || '')}" placeholder="database name e.g. hyundai_usa" />
          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
            <input class="auth-in" name="dbuser" value="${esc(s.db?.user || '')}" placeholder="lavpulse_ro" />
            <input class="auth-in" name="dbpass" value="${esc(s.db?.password || '')}" type="password" placeholder="password" />
          </div>
          <div style="display:flex;gap:8px;align-items:center">
            <button type="button" class="back-link" id="db-test" style="margin:0">Test connection</button>
            <span class="note" id="db-test-out"></span>
          </div>
          <div style="border-top:1px dashed var(--grid);margin:6px 0 2px"></div>
          <label class="note"><b>Media-monitoring database</b> (optional — press-coverage articles, e.g. Ferrari / KGM)</label>
          <div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
            <input class="auth-in" name="monhost" value="${esc(s.monitoring?.host || '')}" placeholder="xxxx.eu-west-2.rds.amazonaws.com" />
            <input class="auth-in" name="monport" value="${esc(s.monitoring?.port || '3306')}" placeholder="3306" />
          </div>
          <input class="auth-in" name="monname" value="${esc(s.monitoring?.database || '')}" placeholder="monitoring database name e.g. ferrari_monitoring" />
          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
            <input class="auth-in" name="monuser" value="${esc(s.monitoring?.user || '')}" placeholder="lavpulse_ro" />
            <input class="auth-in" name="monpass" value="${esc(s.monitoring?.password || '')}" type="password" placeholder="password" />
          </div>
          <div style="display:flex;gap:8px;align-items:center">
            <button type="button" class="back-link" id="mon-test" style="margin:0">Test monitoring DB</button>
            <span class="note" id="mon-test-out"></span>
          </div>
          <label class="mkt-opt" style="margin-top:4px"><input type="checkbox" name="mononly" ${s.monitoringOnly ? 'checked' : ''}/> <b>Media monitoring ONLY</b> <span class="note">— this client has no GA4, platform or email data (e.g. Ferrari): the dashboard and client links show just the coverage report, no analytics sections.</span></label>
          <label class="note">Client link password (optional — anyone opening this client's share link must enter it)</label>
          <input class="auth-in" name="sharepw" value="${esc(s.sharePassword || '')}" placeholder="leave empty for no password" autocomplete="off" />
          <button class="btn-primary">${s.id ? 'Save changes' : 'Add website'}</button>
        </form>`);
      body.querySelector('#mon-test').addEventListener('click', async () => {
        const f = new FormData(body.querySelector('#site-form'));
        const out = body.querySelector('#mon-test-out');
        out.textContent = 'Connecting…';
        try {
          const r = await api('/api/db-test', { method: 'POST', body: JSON.stringify({
            mode: 'monitoring', host: f.get('monhost'), port: f.get('monport'),
            database: f.get('monname'), user: f.get('monuser'), password: f.get('monpass'),
          }) });
          out.innerHTML = r.ok
            ? (r.detectedKind === 'mynewspress'
              ? `<span style="color:var(--good-text)">✓ Connected — <b>myNewspress</b> schema detected, ${Number(r.articles || 0).toLocaleString()} articles (full joins: sentiment, publications, regions, models, clients, press releases)</span>`
              : r.hint
                ? `<span style="color:var(--warn-text)">⚠ ${esc(r.hint)}</span>
                   ${r.tenants?.length ? `<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
                     ${r.tenants.map((t2) => `<button type="button" class="mini-btn tenant-pick" data-db="${esc(t2.database)}" title="Tenant #${esc(String(t2.id))}${t2.domain ? ` · ${esc(t2.domain)}` : ''} → database ${esc(t2.database)}"><b>${esc(t2.name || t2.id)}</b> <span class="note">${esc(t2.domain || t2.database)}</span></button>`).join('')}
                   </div><div class="note" style="margin-top:4px">↑ Click this client's tenant — the database name fills in, then press Test again.</div>` : ''}`
                : r.monitoringTable
                  ? `<span style="color:var(--good-text)">✓ Connected — articles table <b>${esc(r.monitoringTable)}</b>, ${Number(r.articles || 0).toLocaleString()} articles</span>`
                  : `<span style="color:var(--warn-text)">✓ Connected (${r.tables} tables) but no articles-style table found — it will be probed on first load.</span>`)
            : `<span style="color:var(--bad-text)">✗ ${esc(r.error)}</span>`;
          out.querySelectorAll('.tenant-pick').forEach((tb) =>
            tb.addEventListener('click', () => {
              const inp = body.querySelector('[name="monname"]');
              if (inp) { inp.value = tb.dataset.db; inp.style.borderColor = 'var(--good-text)'; }
              out.querySelector('.note') && (out.querySelector('.note').textContent = `Database set to ${tb.dataset.db} — press "Test monitoring DB" again, then Save.`);
            })
          );
        } catch (e2) { out.innerHTML = `<span style="color:var(--bad-text)">✗ ${esc(e2.message)}</span>`; }
      });
      body.querySelector('#db-test').addEventListener('click', async () => {
        const f = new FormData(body.querySelector('#site-form'));
        const out = body.querySelector('#db-test-out');
        out.textContent = 'Connecting…';
        try {
          const r = await api('/api/db-test', { method: 'POST', body: JSON.stringify({
            host: f.get('dbhost'), port: f.get('dbport'), database: f.get('dbname'),
            user: f.get('dbuser'), password: f.get('dbpass'),
          }) });
          out.innerHTML = r.ok
            ? `<span style="color:var(--good-text)">✓ Connected — ${r.tables} tables, ${Number(r.releases).toLocaleString()} releases, looks like <b>${esc(r.detectedKind)}</b></span>`
            : `<span style="color:var(--bad-text)">✗ ${esc(r.error)}</span>`;
          // Auto-select the detected type so Save keeps the connection.
          if (r.ok && r.detectedKind && r.detectedKind !== 'unknown') {
            const sel = body.querySelector('[name="dbkind"]');
            if (sel && !sel.value) sel.value = r.detectedKind;
          }
        } catch (e2) { out.innerHTML = `<span style="color:var(--bad-text)">✗ ${esc(e2.message)}</span>`; }
      });
      // CI helpers: colour pickers fill the hex fields; logo upload embeds
      // the image as a data URI so nothing external needs hosting.
      const linkPick = (pickId, inputName) => {
        const pick = body.querySelector('#' + pickId), inp = body.querySelector(`[name="${inputName}"]`);
        if (!pick || !inp) return;
        pick.addEventListener('input', () => { inp.value = pick.value; });
        inp.addEventListener('input', () => { if (/^#[0-9a-fA-F]{6}$/.test(inp.value.trim())) pick.value = inp.value.trim(); });
      };
      linkPick('cip-pick', 'ciprimary');
      linkPick('cia-pick', 'ciaccent');
      body.querySelector('#ci-logo-file')?.addEventListener('change', (ev) => {
        const file = ev.target.files?.[0];
        const noteEl = body.querySelector('#ci-logo-note');
        if (!file) return;
        if (file.size > 400 * 1024) { noteEl.innerHTML = '<span style="color:var(--bad-text)">Logo too large — keep it under 400 KB (a PNG ~600px wide is plenty).</span>'; return; }
        const rd = new FileReader();
        rd.onload = () => {
          body.querySelector('[name="cilogo"]').value = rd.result;
          noteEl.innerHTML = `<span style="color:var(--good-text)">✓ ${esc(file.name)} attached — save to apply</span> <img src="${rd.result}" style="height:22px;vertical-align:middle;margin-left:6px"/>`;
        };
        rd.readAsDataURL(file);
      });
      body.querySelector('#site-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const f = Object.fromEntries(new FormData(e.target).entries());
        // Start from the EXISTING site so settings this form doesn't edit
        // (market renames, monitoring.mediaBaseUrl, future flags) survive.
        const out = {
          ...s,
          id: (f.id || f.name).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
          name: f.name.trim(),
          url: f.url.trim(),
          // Empty = a media-monitoring-only client (no GA4): stored as 'none',
          // and the dashboard skips the analytics sections for that site.
          propertyId: f.propertyId.trim() || 'none',
        };
        delete out.monitoringOnly; // re-set below from the checkbox
        if (f.releasePath.trim()) out.releasePath = f.releasePath.trim(); else delete out.releasePath;
        if (!(f.dbhost && f.dbhost.trim())) delete out.db;
        if (!(f.monhost && f.monhost.trim())) delete out.monitoring;
        // DB details must NEVER be dropped silently: if the host is filled but
        // no type is chosen, auto-detect it via a test connection; if that
        // fails, block the save and say so instead of quietly discarding.
        if (f.dbhost.trim()) {
          let kind = f.dbkind;
          const testOut = body.querySelector('#db-test-out');
          if (!kind) {
            testOut.textContent = 'Detecting database type…';
            try {
              const r = await api('/api/db-test', { method: 'POST', body: JSON.stringify({
                host: f.dbhost.trim(), port: f.dbport, database: f.dbname.trim(),
                user: f.dbuser.trim(), password: f.dbpass,
              }) });
              if (r.ok && r.detectedKind && r.detectedKind !== 'unknown') kind = r.detectedKind;
              else if (!r.ok) { testOut.innerHTML = `<span style="color:var(--bad-text)">✗ Not saved — ${esc(r.error)}</span>`; return; }
            } catch (e2) { testOut.innerHTML = `<span style="color:var(--bad-text)">✗ Not saved — ${esc(e2.message)}</span>`; return; }
          }
          if (!kind) {
            testOut.innerHTML = '<span style="color:var(--bad-text)">✗ Not saved — pick the database type (Newsroom / Newspress market) above; it could not be auto-detected.</span>';
            return;
          }
          out.db = { kind, host: f.dbhost.trim(), port: Number(f.dbport || 3306) || 3306,
            database: f.dbname.trim(), user: f.dbuser.trim(), password: f.dbpass };
        }
        // Client identity — colours, font, logo. Cleared fields are removed;
        // an entirely empty CI drops the object so the defaults return.
        const ciP = (f.ciprimary || '').trim(), ciA = (f.ciaccent || '').trim();
        const ciF = (f.cifont || '').trim(), ciL = (f.cilogo || '').trim();
        if (ciP || ciA || ciF || ciL) {
          out.ci = { ...(s.ci || {}) };
          if (ciP) out.ci.primary = ciP; else delete out.ci.primary;
          if (ciA) out.ci.accent = ciA; else delete out.ci.accent;
          if (ciF) out.ci.font = ciF; else delete out.ci.font;
          if (ciL) out.ci.logo = ciL; else delete out.ci.logo;
        } else delete out.ci;
        // Optional monitoring DB — saved whenever a host is filled, never dropped.
        if (f.monhost && f.monhost.trim()) {
          out.monitoring = { ...(s.monitoring || {}), host: f.monhost.trim(), port: Number(f.monport || 3306) || 3306,
            database: f.monname.trim(), user: f.monuser.trim(), password: f.monpass };
        }
        // "Media monitoring ONLY" — hide analytics for this client everywhere.
        if (f.mononly) out.monitoringOnly = true;
        // Optional client-link password
        if ((f.sharepw || '').trim()) out.sharePassword = f.sharepw.trim(); else delete out.sharePassword;
        const em = {};
        if (f.sparkpost.trim()) em.sparkpost = f.sparkpost.trim();
        if (f.brevo.trim()) em.brevo = f.brevo.trim();
        if (Object.keys(em).length) out.email = em;
        closeModal();
        onSave(out);
      });
    };

    document.getElementById('add-site').addEventListener('click', () =>
      siteForm({}, (site) => save([...sites, site]))
    );
    document.getElementById('import-central').addEventListener('click', () => {
      const body = openModal(`
        <h3>Import all sites from the central reports DB</h3>
        <p class="note">Reads the <code>sites</code> registry (name, type, database key) and creates every newsroom and Newspress market here in one go — with its database connection pre-filled, assuming the per-site DBs live on the same MySQL host. GA4 property IDs start as "demo"; fill them in per site afterwards.</p>
        <form id="central-form" class="auth-form" style="margin-top:10px">
          <div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
            <input class="auth-in" name="host" placeholder="xxxx.eu-west-2.rds.amazonaws.com" required />
            <input class="auth-in" name="port" value="3306" />
          </div>
          <input class="auth-in" name="database" placeholder="central database name (e.g. reports)" required />
          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
            <input class="auth-in" name="user" placeholder="lavpulse_ro" required />
            <input class="auth-in" name="password" type="password" placeholder="password" required />
          </div>
          <button class="btn-primary">Import sites</button>
          <div class="note" id="central-out"></div>
        </form>`);
      body.querySelector('#central-form').addEventListener('submit', async (e) => {
        e.preventDefault();
        const out = body.querySelector('#central-out');
        out.textContent = 'Reading the central sites registry…';
        try {
          const r = await api('/api/sites-config/import-central', {
            method: 'POST', body: JSON.stringify(Object.fromEntries(new FormData(e.target).entries())),
          });
          out.innerHTML = `<span style="color:var(--good-text)">✓ Imported ${r.imported} of ${r.found} sites (${r.skipped} already existed or were skipped).</span>`;
          state.config = await api('/api/config');
          setTimeout(() => { closeModal(); renderShell(); }, 1400);
        } catch (err) {
          out.innerHTML = `<span style="color:var(--bad-text)">✗ ${esc(err.message)}</span>`;
        }
      });
    });
    el.querySelectorAll('tr[data-i]').forEach((tr) => {
      const i = Number(tr.dataset.i);
      tr.querySelector('.edit-btn').addEventListener('click', () =>
        siteForm(sites[i], (site) => save(sites.map((s, j) => (j === i ? site : s))))
      );
      tr.querySelector('.del-btn').addEventListener('click', (e) => {
        if (e.target.dataset.arm) return save(sites.filter((_, j) => j !== i));
        e.target.dataset.arm = '1'; e.target.textContent = 'Really delete?';
        setTimeout(() => { e.target.dataset.arm = ''; e.target.textContent = 'Delete'; }, 2500);
      });
    });
  }

  // ── reports page ─────────────────────────────────────────────────────
  // ── Activity & error logs (superadmin) ─────────────────────────────────
  const ACT_BADGE = {
    'login': ['var(--good-text)', '⇥'], 'login-failed': ['var(--bad-text)', '⚠'], 'logout': ['var(--text-muted)', '⇤'],
    'report-generated': ['var(--series-2)', '▤'], 'scheduled-report': ['var(--series-2)', '⏰'], 'report-deleted': ['var(--bad-text)', '✕'],
    'ai-question': ['var(--series-1)', '✦'], 'user-created': ['var(--good-text)', '◉'], 'user-updated': ['var(--warn-text)', '◉'],
    'user-deleted': ['var(--bad-text)', '◉'], 'sites-config-saved': ['var(--warn-text)', '✦'], 'share-link-created': ['var(--series-2)', '⛓'],
    'schedule-saved': ['var(--series-2)', '⏰'], 'schedule-deleted': ['var(--bad-text)', '⏰'],
    'account-setup': ['var(--good-text)', '★'], 'register': ['var(--warn-text)', '＋'],
  };
  async function renderActivity(el) {
    state.actTab = state.actTab || 'activity';
    const tab = state.actTab;
    const q = state.actQ || '';
    let entries = [];
    try { entries = (await api(`/api/admin/logs?kind=${tab}&q=${encodeURIComponent(q)}`)).entries; }
    catch (err) { el.innerHTML = `<div class="err">${esc(err.message)}</div>`; return; }
    const when = (t) => {
      const d2 = new Date(t);
      return `${d2.toISOString().slice(0, 10)} <b>${d2.toTimeString().slice(0, 8)}</b>`;
    };
    const detailStr = (d2) => Object.entries(d2 || {}).map(([k, v]) => `<span class="fact-pill" style="font-size:11px;padding:2px 8px">${esc(k)}: <b>${esc(String(v))}</b></span>`).join(' ');
    el.innerHTML = `
      <div class="grid" style="gap:16px">
        <div class="card">
          <div class="card-head">
            <h3>Activity &amp; error logs</h3>
            <div style="display:flex;gap:8px;align-items:center">
              <button class="chat-toggle ${tab === 'activity' ? 'active' : ''}" id="act-tab-a" ${tab === 'activity' ? 'style="background:var(--btn-bg);color:var(--btn-fg)"' : ''}>👤 User activity</button>
              <button class="chat-toggle ${tab === 'errors' ? 'active' : ''}" id="act-tab-e" ${tab === 'errors' ? 'style="background:var(--btn-bg);color:var(--btn-fg)"' : ''}>⚠ Error log</button>
              <button class="chat-toggle" id="act-refresh" title="Reload">↻</button>
            </div>
          </div>
          <input class="card-search" id="act-q" placeholder="Search ${tab === 'errors' ? 'errors — message, source, path…' : 'activity — user, action, site, IP…'}" value="${esc(q)}" style="width:100%;max-width:420px;margin:8px 0" />
          <div class="note" style="margin-bottom:8px">${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}${q ? ` matching “${esc(q)}”` : ''} — newest first. ${tab === 'activity' ? 'Every login, report, AI question and admin change.' : 'API failures, scheduler errors and everything the server reports.'}</div>
          <div style="overflow-x:auto">
          ${tab === 'activity' ? `
            <table class="data">
              <thead><tr><th>When</th><th>User</th><th>Action</th><th>Details</th><th>IP</th></tr></thead>
              <tbody>${entries.map((e2) => {
                const [col, ic] = ACT_BADGE[e2.action] || ['var(--text-muted)', '·'];
                return `<tr>
                  <td style="white-space:nowrap;font-size:12px">${when(e2.at)}</td>
                  <td style="font-weight:600">${esc(e2.user || '')}${e2.role ? ` <span class="note" style="font-size:10.5px">${esc(e2.role)}</span>` : ''}</td>
                  <td style="white-space:nowrap"><span style="color:${col};font-weight:700">${ic} ${esc(e2.action)}</span></td>
                  <td>${detailStr(e2.detail)}</td>
                  <td class="note" style="font-size:11px">${esc(e2.ip || '')}</td>
                </tr>`;
              }).join('')}</tbody>
            </table>`
          : `
            <table class="data">
              <thead><tr><th>When</th><th>Source</th><th>Error</th></tr></thead>
              <tbody>${entries.map((e2) => `<tr>
                <td style="white-space:nowrap;font-size:12px;vertical-align:top">${when(e2.at)}</td>
                <td style="vertical-align:top"><span style="color:var(--bad-text);font-weight:700">${esc(e2.source || '')}</span>${e2.extra?.path ? `<div class="note" style="font-size:11px">${esc(e2.extra.path)}</div>` : ''}</td>
                <td><div style="font-weight:600">${esc(e2.message || '')}</div>${e2.stack ? `<div class="note" style="font-size:11px;margin-top:2px;word-break:break-all">${esc(e2.stack)}</div>` : ''}</td>
              </tr>`).join('')}</tbody>
            </table>`}
          </div>
          ${!entries.length ? `<div class="note" style="padding:14px 0">${tab === 'errors' ? 'No errors logged — all clear. 🎉' : 'No activity recorded yet — entries appear as people log in and use the dashboard.'}</div>` : ''}
        </div>
      </div>`;
    document.getElementById('act-tab-a').addEventListener('click', () => { state.actTab = 'activity'; renderActivity(el); });
    document.getElementById('act-tab-e').addEventListener('click', () => { state.actTab = 'errors'; renderActivity(el); });
    document.getElementById('act-refresh').addEventListener('click', () => renderActivity(el));
    let actT = null;
    document.getElementById('act-q').addEventListener('input', (ev) => {
      clearTimeout(actT);
      actT = setTimeout(() => { state.actQ = ev.target.value; renderActivity(el); }, 350);
    });
  }

  // ── Reports — grouped by client, searchable, with scheduling ───────────
  const FMT_ICON = { pptx: '📊', pdf: '📄', html: '🌐' };
  const SRC_BADGE = { manual: ['Manual', 'var(--text-muted)'], ai: ['AI', 'var(--series-1)'], schedule: ['Scheduled', 'var(--series-2)'] };
  async function renderReports(el) {
    const reportLink = (f) => `/reports/${encodeURIComponent(f)}`;
    const [d, schD] = await Promise.all([
      api('/api/reports'),
      api('/api/report-schedules').catch(() => ({ schedules: [], mailConfigured: false })),
    ]);
    const q = (state.repQ || '').toLowerCase();
    const fClient = state.repClient || '';
    const siteName = (id) => (d.sites.find((s2) => s2.id === id)?.name) || (id === 'all' ? 'All sites (portfolio)' : id) || 'Other';
    const filtered = d.reports.filter((r2) =>
      (!fClient || r2.siteId === fClient)
      && (!q || r2.file.toLowerCase().includes(q) || (r2.title || '').toLowerCase().includes(q) || siteName(r2.siteId).toLowerCase().includes(q) || (r2.style || '').includes(q)));
    // Group by client, groups ordered by their newest report.
    const groups = new Map();
    for (const r2 of filtered) {
      const key = r2.siteId || 'other';
      if (!groups.has(key)) groups.set(key, []);
      groups.get(key).push(r2);
    }
    const ordered = [...groups.entries()].sort((a, b) => Math.max(...b[1].map((x) => x.at)) - Math.max(...a[1].map((x) => x.at)));
    const fmtDate = (t) => t ? new Date(t).toISOString().slice(0, 16).replace('T', ' ') : '';
    const fmtSize = (b) => b > 1e6 ? (b / 1e6).toFixed(1) + ' MB' : b > 1000 ? Math.round(b / 1000) + ' KB' : b + ' B';
    const STYLE_LBL = { full: 'Full report', table: 'Client table', monitoring: 'Monitoring', release: 'Release deck' };
    const FREQ_LBL = (s2) => s2.frequency === 'daily' ? 'Daily' : s2.frequency === 'weekly' ? `Weekly (${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][s2.day % 7]})` : `Monthly (day ${s2.day})`;
    const row = (r2) => {
      const [sl, sc] = SRC_BADGE[r2.source] || ['', ''];
      return `<div style="display:flex;align-items:center;gap:10px;padding:7px 0;border-top:1px solid var(--grid)">
        <span style="font-size:16px">${FMT_ICON[r2.format] || '▤'}</span>
        <div style="flex:1;min-width:0">
          <a href="${reportLink(r2.file)}" target="_blank" style="color:var(--accent);font-weight:600;word-break:break-all">${esc(r2.title || r2.file)}</a>
          <div class="note" style="font-size:11px">${esc(r2.file)} · ${fmtDate(r2.at)} · ${fmtSize(r2.size)}${r2.style ? ' · ' + esc(STYLE_LBL[r2.style] || r2.style) : ''}</div>
        </div>
        ${sl ? `<span style="font-size:10.5px;font-weight:700;color:${sc};border:1px solid ${sc};border-radius:6px;padding:1px 7px;white-space:nowrap">${sl}</span>` : ''}
        <span style="font-size:10.5px;font-weight:700;color:var(--text-muted);text-transform:uppercase">${esc(r2.format)}</span>
        <button class="mini-btn rep-del" data-file="${esc(r2.file)}" title="Delete this report">✕</button>
      </div>`;
    };
    el.innerHTML = `
      <div class="grid" style="gap:16px">
        <div class="card">
          <h3>Generate a report now</h3>
          <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <select id="rep-scope" class="rep-select">
              <option value="all">All sites (portfolio)</option>
              ${state.config.sites.map((s) => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('')}
            </select>
            <select id="rep-style" class="rep-select">
              <option value="full">Full report — charts, key facts, AI summary</option>
              <option value="table">Client table — the main release table by date (what clients get)</option>
              <option value="monitoring">myNewspress monitoring — coverage, sentiment, SOV + AI key insight</option>
            </select>
            <select id="rep-format" class="rep-select">
              <option value="pdf">📄 PDF / HTML report</option>
              <option value="pptx">📊 PowerPoint deck</option>
            </select>
            <button class="chat-toggle" id="rep-gen">Generate for last ${esc(state.range)}</button>
            <span class="note" id="rep-status"></span>
          </div>
        </div>
        <div class="card">
          <div class="card-head">
            <h3>Scheduled reports</h3>
            <button class="chat-toggle" id="sch-add">＋ New schedule</button>
          </div>
          ${schD.schedules.length ? `<div style="overflow-x:auto"><table class="data">
            <thead><tr><th>Client</th><th>Report</th><th>Format</th><th>When</th><th>Period</th><th>Email to</th><th>Last run</th><th></th></tr></thead>
            <tbody>${schD.schedules.map((s2) => `<tr style="${s2.enabled ? '' : 'opacity:0.45'}">
              <td style="font-weight:600">${esc(siteName(s2.siteId))}</td>
              <td>${esc(STYLE_LBL[s2.style] || s2.style)}</td>
              <td>${FMT_ICON[s2.format] || ''} ${esc(s2.format.toUpperCase())}</td>
              <td>${FREQ_LBL(s2)} · ${String(s2.hour).padStart(2, '0')}:00</td>
              <td>last ${esc(s2.range)}</td>
              <td class="note" style="font-size:11.5px">${esc(s2.emails || '—')}</td>
              <td class="note" style="font-size:11.5px">${s2.lastRun ? esc(s2.lastRun) + (s2.lastFile ? ` · <a href="${reportLink(s2.lastFile)}" target="_blank" style="color:var(--accent)">open</a>` : '') : 'never'}</td>
              <td style="white-space:nowrap">
                <button class="mini-btn sch-toggle" data-id="${esc(s2.id)}" data-en="${s2.enabled ? '1' : ''}" title="${s2.enabled ? 'Pause' : 'Resume'}">${s2.enabled ? '⏸' : '▶'}</button>
                <button class="mini-btn sch-edit" data-id="${esc(s2.id)}">✏️</button>
                <button class="mini-btn sch-del" data-id="${esc(s2.id)}">✕</button>
              </td>
            </tr>`).join('')}</tbody></table></div>`
          : '<div class="note">No schedules yet — click “＋ New schedule” to have a client\'s report generated automatically every day, week or month, and emailed if you add recipients.</div>'}
          ${!schD.mailConfigured ? '<div class="note" style="margin-top:8px">✉️ Emailing needs SMTP details in <code>.env</code> (SMTP_HOST, SMTP_USER…) — without them, scheduled reports are still generated and appear below.</div>' : ''}
        </div>
        <div class="card">
          <div class="card-head">
            <h3>Generated reports <span class="note" style="font-weight:400">— ${filtered.length} of ${d.reports.length}</span></h3>
            <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
              <select id="rep-client-f" class="rep-select" style="min-width:160px">
                <option value="">All clients</option>
                <option value="all" ${fClient === 'all' ? 'selected' : ''}>Portfolio reports</option>
                ${d.sites.map((s2) => `<option value="${esc(s2.id)}" ${fClient === s2.id ? 'selected' : ''}>${esc(s2.name)}</option>`).join('')}
              </select>
              <input class="card-search" id="rep-q" placeholder="Search reports…" value="${esc(state.repQ || '')}" />
            </div>
          </div>
          ${ordered.length ? ordered.map(([sid, rows]) => `
            <div style="margin:14px 0 4px;font-size:14px;font-weight:800;display:flex;align-items:center;gap:8px">
              ${esc(siteName(sid))} <span class="note" style="font-weight:400">· ${rows.length} report${rows.length > 1 ? 's' : ''}</span>
            </div>
            ${rows.sort((a, b) => b.at - a.at).slice(0, state.repShowAll?.[sid] ? 500 : 8).map(row).join('')}
            ${rows.length > 8 && !state.repShowAll?.[sid] ? `<button class="mini-btn rep-more" data-sid="${esc(sid)}" style="margin-top:6px">Show all ${rows.length}…</button>` : ''}
          `).join('')
          : `<div class="note" style="padding:12px 0">${q || fClient ? 'Nothing matches this search/filter.' : 'No reports yet — generate one above.'}</div>`}
        </div>
      </div>`;
    // ── wiring ──
    document.getElementById('rep-gen').addEventListener('click', async () => {
      const status = document.getElementById('rep-status');
      const scope = document.getElementById('rep-scope').value;
      status.textContent = 'Generating from live data…';
      try {
        const r = await api('/api/reports/generate', { method: 'POST', body: JSON.stringify({ scope, range: state.range, style: document.getElementById('rep-style').value, format: document.getElementById('rep-format').value }) });
        status.innerHTML = `Done — <a href="${reportLink(r.pdfFilename || r.filename)}" target="_blank" style="color:var(--accent);font-weight:600">open</a>`;
        renderReports(el);
      } catch (err) { status.textContent = `Failed: ${err.message}`; }
    });
    let repT = null;
    document.getElementById('rep-q').addEventListener('input', (ev) => {
      clearTimeout(repT);
      repT = setTimeout(() => { state.repQ = ev.target.value; renderReports(el); }, 300);
    });
    document.getElementById('rep-client-f').addEventListener('change', (ev) => { state.repClient = ev.target.value; renderReports(el); });
    el.querySelectorAll('.rep-more').forEach((b) => b.addEventListener('click', () => {
      state.repShowAll = { ...(state.repShowAll || {}), [b.dataset.sid]: true }; renderReports(el);
    }));
    el.querySelectorAll('.rep-del').forEach((b) => b.addEventListener('click', async () => {
      if (!confirm(`Delete ${b.dataset.file}? This cannot be undone.`)) return;
      try { await api('/api/reports/delete', { method: 'POST', body: JSON.stringify({ file: b.dataset.file }) }); renderReports(el); }
      catch (err) { alert('Delete failed: ' + err.message); }
    }));
    const schForm = (s2 = {}) => {
      const body = openModal(`
        <h3>${s2.id ? 'Edit schedule' : 'New report schedule'}</h3>
        <form id="sch-form" class="auth-form" style="margin-top:12px">
          <label class="note">Client</label>
          <select class="rep-select" name="siteId" style="width:100%">
            <option value="all" ${s2.siteId === 'all' ? 'selected' : ''}>All sites (portfolio)</option>
            ${state.config.sites.map((x) => `<option value="${esc(x.id)}" ${s2.siteId === x.id ? 'selected' : ''}>${esc(x.name)}</option>`).join('')}
          </select>
          <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
            <div><label class="note">Report</label>
            <select class="rep-select" name="style" style="width:100%">
              <option value="full" ${s2.style === 'full' ? 'selected' : ''}>Full report</option>
              <option value="table" ${s2.style === 'table' ? 'selected' : ''}>Client table</option>
              <option value="monitoring" ${s2.style === 'monitoring' ? 'selected' : ''}>myNewspress monitoring</option>
            </select></div>
            <div><label class="note">Format</label>
            <select class="rep-select" name="format" style="width:100%">
              <option value="pdf" ${s2.format !== 'pptx' ? 'selected' : ''}>📄 PDF report</option>
              <option value="pptx" ${s2.format === 'pptx' ? 'selected' : ''}>📊 PowerPoint deck</option>
            </select></div>
          </div>
          <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
            <div><label class="note">Frequency</label>
            <select class="rep-select" name="frequency" id="sch-freq" style="width:100%">
              <option value="daily" ${s2.frequency === 'daily' ? 'selected' : ''}>Daily</option>
              <option value="weekly" ${!s2.frequency || s2.frequency === 'weekly' ? 'selected' : ''}>Weekly</option>
              <option value="monthly" ${s2.frequency === 'monthly' ? 'selected' : ''}>Monthly</option>
            </select></div>
            <div><label class="note" id="sch-day-lbl">Day</label>
            <select class="rep-select" name="day" id="sch-day" style="width:100%"></select></div>
            <div><label class="note">Hour</label>
            <select class="rep-select" name="hour" style="width:100%">
              ${Array.from({ length: 24 }, (_, h) => `<option value="${h}" ${(s2.hour ?? 7) === h ? 'selected' : ''}>${String(h).padStart(2, '0')}:00</option>`).join('')}
            </select></div>
          </div>
          <label class="note">Period covered</label>
          <select class="rep-select" name="range" style="width:100%">
            ${['7d', '28d', '90d', 'mtd', '12mo'].map((rg) => `<option value="${rg}" ${(s2.range || '28d') === rg ? 'selected' : ''}>last ${rg}</option>`).join('')}
          </select>
          <label class="note">Email to (optional — comma-separated; needs SMTP in .env)</label>
          <input class="auth-in" name="emails" value="${esc(s2.emails || '')}" placeholder="client@brand.com, account.manager@lav.co.uk" />
          <button class="btn-primary" type="submit" style="margin-top:10px">${s2.id ? 'Save changes' : 'Create schedule'}</button>
        </form>`);
      const freqSel = body.querySelector('#sch-freq'), daySel = body.querySelector('#sch-day');
      const fillDays = () => {
        const f2 = freqSel.value;
        body.querySelector('#sch-day-lbl').textContent = f2 === 'monthly' ? 'Day of month' : f2 === 'weekly' ? 'Weekday' : 'Day';
        daySel.innerHTML = f2 === 'monthly'
          ? Array.from({ length: 28 }, (_, i) => `<option value="${i + 1}" ${(s2.day || 1) === i + 1 ? 'selected' : ''}>${i + 1}</option>`).join('')
          : f2 === 'weekly'
            ? ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map((n2, i) => `<option value="${i}" ${(s2.day ?? 1) === i ? 'selected' : ''}>${n2}</option>`).join('')
            : '<option value="0">—</option>';
        daySel.disabled = f2 === 'daily';
      };
      freqSel.addEventListener('change', fillDays);
      fillDays();
      body.querySelector('#sch-form').addEventListener('submit', async (ev) => {
        ev.preventDefault();
        const f2 = Object.fromEntries(new FormData(ev.target).entries());
        try {
          await api('/api/report-schedules/save', { method: 'POST', body: JSON.stringify({ ...s2, ...f2, id: s2.id }) });
          closeModal(); renderReports(el);
        } catch (err) { alert('Save failed: ' + err.message); }
      });
    };
    document.getElementById('sch-add').addEventListener('click', () => schForm({}));
    el.querySelectorAll('.sch-edit').forEach((b) => b.addEventListener('click', () => {
      const s2 = schD.schedules.find((x) => x.id === b.dataset.id);
      if (s2) schForm(s2);
    }));
    el.querySelectorAll('.sch-toggle').forEach((b) => b.addEventListener('click', async () => {
      const s2 = schD.schedules.find((x) => x.id === b.dataset.id);
      if (!s2) return;
      await api('/api/report-schedules/save', { method: 'POST', body: JSON.stringify({ ...s2, enabled: !s2.enabled }) }).catch((e2) => alert(e2.message));
      renderReports(el);
    }));
    el.querySelectorAll('.sch-del').forEach((b) => b.addEventListener('click', async () => {
      if (!confirm('Delete this schedule?')) return;
      await api('/api/report-schedules/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }).catch((e2) => alert(e2.message));
      renderReports(el);
    }));
  }

  // ── insights card ────────────────────────────────────────────────────
  const INSIGHT_META = {
    up: ['▲', 'Win'], down: ['▼', 'Down'], watch: ['⚠', 'Watch'], info: ['•', 'Note'],
  };
  function renderInsights(scope) {
    const el = document.getElementById('insights-card');
    if (!el) return;
    const sig = scope + '|' + state.range;
    // Serve from client cache on cosmetic re-renders (sorting, searching).
    if (state.insightsCache?.sig === sig) { paintInsights(el, scope, state.insightsCache.data); return; }
    el.innerHTML = '<h3>Key facts & suggestions</h3><div class="note">Analysing this period…</div>';
    api(`/api/insights?scope=${encodeURIComponent(scope)}&range=${encodeURIComponent(state.range)}`)
      .then((d) => {
        state.insightsCache = { sig, data: d };
        paintInsights(el, scope, d);
      })
      .catch((err) => {
        if (err.message !== 'unauthorised')
          el.innerHTML = `<h3>Key facts & suggestions</h3><div class="note">Couldn't analyse: ${esc(err.message)}</div>`;
      });
  }

  function paintInsights(el, scope, d) {
        const items = d.insights.map((i) => {
          const [icon, label] = INSIGHT_META[i.kind] || INSIGHT_META.info;
          return `<div class="insight">
            <span class="ins-ic ${i.kind}" title="${label}">${icon}<span class="ins-lbl">${label}</span></span>
            <div class="ins-body">
              <div class="ins-fact">${esc(i.fact)}</div>
              ${i.suggestion ? `<div class="ins-sugg">→ ${esc(i.suggestion)}</div>` : ''}
            </div>
          </div>`;
        }).join('');
        el.innerHTML = `<h3>Key facts & suggestions</h3>
          <div class="insights">${items || '<div class="note">Nothing notable this period.</div>'}</div>
          ${state.config?.chatConfigured
            ? '<button class="back-link" id="ai-sum" style="margin-top:10px">✦ Generate AI executive summary</button><div id="ai-sum-out"></div>'
            : ''}`;
        const btn = document.getElementById('ai-sum');
        if (btn)
          btn.addEventListener('click', async () => {
            btn.disabled = true; btn.textContent = 'Writing summary from live data…';
            try {
              const r = await api('/api/ai-summary', { method: 'POST', body: JSON.stringify({ scope, range: state.range }) });
              document.getElementById('ai-sum-out').innerHTML =
                `<div class="msg assistant ai-summary">${md(r.content)}</div>`;
              btn.style.display = 'none';
            } catch (err) {
              btn.disabled = false; btn.textContent = '✦ Generate AI executive summary';
              document.getElementById('ai-sum-out').innerHTML = `<div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
            }
          });
  }

  // ── channels card with drill-down ────────────────────────────────────
  function renderChannelsCard(siteId, homeRows, selected) {
    const el = document.getElementById('channels-card');
    if (!el) return;
    if (!selected) {
      const max = Math.max(1, ...homeRows.map((r) => r.sessions || 0));
      el.innerHTML = `<div class="card-head"><h3>Traffic channels</h3>${listControls('channels')}</div>` + homeRows.map((r) => `
        <div class="bar-row clickable" data-ch="${esc(r.dimensionValue)}" title="Click to see sources in ${esc(r.dimensionValue)}">
          <div class="bar-label">${esc(r.dimensionValue)}</div>
          <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r.sessions || 0)) / max}%"></div></div>
          <div class="bar-value">${fmtInt(r.sessions)}</div>
        </div>`).join('') +
        `<div class="note" style="margin-top:8px">Click a channel to see its sources & landing pages</div>`;
      bindListControls(el, siteId);
      el.querySelectorAll('[data-ch]').forEach((row) =>
        row.addEventListener('click', async () => {
          const ch = row.dataset.ch;
          el.innerHTML = `<h3>Traffic channels — <span class="crumb">${esc(ch)}</span></h3><div class="note">Loading…</div>`;
          try {
            const d = await api(`/api/site/${encodeURIComponent(siteId)}/channel?channel=${encodeURIComponent(ch)}&range=${encodeURIComponent(state.range)}`);
            renderChannelsCard(siteId, homeRows, d);
          } catch (err) {
            el.innerHTML = `<h3>Traffic channels</h3><div class="err">${esc(err.message)}</div>`;
          }
        })
      );
      return;
    }
    el.innerHTML = `
      <h3>Channel — <span class="crumb">${esc(selected.channel)}</span></h3>
      <button class="back-link" id="ch-back">← All channels</button>
      <h3 style="margin-top:6px">Sources</h3>
      ${selected.sources.length ? barList(selected.sources, 'sessions') : '<div class="note">No source data.</div>'}
      <h3 style="margin-top:10px">Landing pages</h3>
      ${selected.landing.length ? barList(selected.landing, 'sessions') : '<div class="note">No landing-page data.</div>'}`;
    el.querySelector('#ch-back').addEventListener('click', () => renderChannelsCard(siteId, homeRows, null));
  }

  // ── countries card with city drill-down ──────────────────────────────
  function renderCountriesCard(siteId, homeRows, selected) {
    const el = document.getElementById('countries-card');
    if (!el) return;
    if (!selected) {
      const max = Math.max(1, ...homeRows.map((r) => r.sessions || 0));
      el.innerHTML = `<div class="card-head"><h3>Countries</h3>${listControls('countries')}</div>` + homeRows.map((r) => `
        <div class="bar-row clickable" data-country="${esc(r.dimensionValue)}" title="Click to see cities in ${esc(r.dimensionValue)}">
          <div class="bar-label">${esc(r.dimensionValue)}</div>
          <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r.sessions || 0)) / max}%"></div></div>
          <div class="bar-value">${fmtInt(r.sessions)}</div>
        </div>`).join('') +
        `<div class="note" style="margin-top:8px">Click a country to see its top cities</div>`;
      bindListControls(el, siteId);
      el.querySelectorAll('[data-country]').forEach((row) =>
        row.addEventListener('click', async () => {
          const c = row.dataset.country;
          el.innerHTML = `<h3>Countries — <span class="crumb">${esc(c)}</span></h3><div class="note">Loading…</div>`;
          try {
            const d = await api(`/api/site/${encodeURIComponent(siteId)}/cities?country=${encodeURIComponent(c)}&range=${encodeURIComponent(state.range)}`);
            el.innerHTML = `
              <h3>Cities — <span class="crumb">${esc(c)}</span></h3>
              <button class="back-link" id="country-back">← All countries</button>
              ${d.rows.length ? barList(d.rows, 'sessions') : '<div class="note">No city data for this country.</div>'}`;
            el.querySelector('#country-back').addEventListener('click', () => renderCountriesCard(siteId, homeRows, null));
          } catch (err) {
            el.innerHTML = `<h3>Countries</h3><div class="err">${esc(err.message)}</div>`;
          }
        })
      );
    }
  }

  // ── GA4 fallback search: when the visible list has no match, ask GA4 ─
  let __ga4Timer = null;
  function ga4Search(siteId, q, listEl, customRender) {
    clearTimeout(__ga4Timer);
    __ga4Timer = setTimeout(async () => {
      try {
        const d = await api(`/api/site/${encodeURIComponent(siteId)}/search-pages?q=${encodeURIComponent(q)}&range=${encodeURIComponent(state.range)}`);
        const html = d.rows.length
          ? `<div class="ga4-results"><div class="note" style="margin-bottom:4px">Found in GA4 (this period):</div>` +
            d.rows.map((r) => `<div class="ga4-result" data-path="${esc(r.dimensionValue)}"><span>${esc(r.dimensionValue)}</span><span class="num">${fmtInt(r.screenPageViews)} views</span></div>`).join('') +
            `</div>`
          : `<div class="ga4-results"><div class="note">Nothing in GA4 matches "${esc(q)}" in this period — try a wider date range.</div></div>`;
        if (customRender) { customRender(html); return; }
        if (listEl) {
          listEl.innerHTML = html;
          listEl.querySelectorAll('.ga4-result').forEach((r) =>
            r.addEventListener('click', () => renderPageDetail(siteId, r.dataset.path))
          );
        }
      } catch { /* quiet — the "searching…" note remains */ }
    }, 450);
  }

  // ── modal system ─────────────────────────────────────────────────────
  function escHandler(e) { if (e.key === 'Escape') closeModal(); }
  function closeModal() {
    document.getElementById('lp-modal')?.remove();
    document.removeEventListener('keydown', escHandler);
  }
  function openModal(html) {
    closeModal();
    const ov = document.createElement('div');
    ov.className = 'modal-overlay'; ov.id = 'lp-modal';
    ov.innerHTML = `<div class="modal"><button class="modal-close" title="Close (Esc)">✕</button><div class="modal-body">${html}</div></div>`;
    document.body.appendChild(ov);
    ov.addEventListener('click', (e) => { if (e.target === ov) closeModal(); });
    ov.querySelector('.modal-close').addEventListener('click', closeModal);
    document.addEventListener('keydown', escHandler);
    return ov.querySelector('.modal-body');
  }

  // ── single-page dossier — opens as a popup, keeps your place ─────────
  async function renderPageDetail(siteId, path) {
    const body = openModal(`<h3>Page — <span class="crumb">${esc(path)}</span></h3><div class="note">Loading page dossier…</div>`);
    try {
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/page?path=${encodeURIComponent(path)}&range=${encodeURIComponent(state.range)}`);
      const views = d.trend.reduce((a, r) => a + (r.screenPageViews || 0), 0);
      const activeDays = d.trend.filter((r) => (r.screenPageViews || 0) > 0);
      const peak = d.trend.reduce((a, b) => ((b.screenPageViews || 0) > (a?.screenPageViews ?? -1) ? b : a), null);
      const firstActive = activeDays[0];
      const half = Math.floor(d.trend.length / 2);
      const h1 = d.trend.slice(0, half).reduce((a, r) => a + (r.screenPageViews || 0), 0);
      const h2 = d.trend.slice(half).reduce((a, r) => a + (r.screenPageViews || 0), 0);
      const direction = h1 === 0 && h2 === 0 ? '—' : h2 > h1 * 1.15 ? '↗ building' : h2 < h1 * 0.85 ? '↘ fading' : '→ steady';
      const base = siteUrlFor(siteId);
      const ec = d.emailCampaign;
      // Email-driven readership straight from GA4 sources — works even when
      // no campaign is matched (e.g. email domains not configured yet).
      const emailViews = d.sources
        .filter((s) => /email|newsletter|mailchimp|brevo|sparkpost/i.test(s.dimensionValue))
        .reduce((a, s) => a + (s.screenPageViews || 0), 0);
      body.innerHTML = `
        <h3 style="margin-bottom:6px">${d.legacy?.title ? esc(d.legacy.title) + ' — ' : 'Page — '}<span class="crumb">${esc(path)}</span>${base ? ` <a class="ext" href="${esc(base + path)}" target="_blank" rel="noopener" title="Open on the live site">open on site ↗</a>` : ''}</h3>
        <div class="facts-row">
          <span class="fact-pill"><b>${fmtInt(views)}</b> views this period</span>
          ${d.legacy ? `<span class="fact-pill">📄 <b>${fmtInt(d.legacy.viewed)}</b> platform views</span>` : ''}
          ${d.legacy?.downloaded != null ? `<span class="fact-pill">📥 <b>${fmtInt(d.legacy.downloaded)}</b> downloads</span>` : ''}
          ${peak && peak.screenPageViews ? `<span class="fact-pill">Peak <b>${esc(peak.date)}</b> · ${fmtInt(peak.screenPageViews)} views</span>` : ''}
          ${firstActive ? `<span class="fact-pill">First traffic <b>${esc(firstActive.date)}</b></span>` : ''}
          <span class="fact-pill">Daily avg <b>${fmtInt(views / Math.max(1, activeDays.length))}</b></span>
          <span class="fact-pill">Trend <b>${direction}</b></span>
          ${emailViews ? `<span class="fact-pill">📧 <b>${fmtInt(emailViews)}</b> views via email links${views && emailViews <= views ? ` (${Math.round((100 * emailViews) / views)}%)` : ''}</span>` : ''}
        </div>
        ${ec ? `<div class="email-join">
          📧 <b>Emailed${ec.date ? ' on ' + esc(ec.date) : ''}</b> — "${esc(ec.name)}" (${esc(ec.provider)}):
          ${fmtInt(ec.sent)} recipients → ${fmtInt(ec.opens)} opens (${fmtPct(ec.openRate)})${ec.clicks != null ? ` → ${fmtInt(ec.clicks)} clicks (${fmtPct(ec.clickRate)})` : ''}${emailViews ? ` → <b>${fmtInt(emailViews)}</b> views landed on this page` : ''}
        </div>` : emailViews ? `<div class="email-join">
          📧 <b>${fmtInt(emailViews)} views arrived via email links</b> — no campaign is linked to this site yet.
          Add this site's sending domains to its "email" entry in sites.config.json to see the full funnel (recipients → opens → clicks → views).
        </div>` : ''}
        <div id="pd-trend"></div>
        <div class="grid" style="grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:12px">
          <div><h3>Where readers came from</h3>${d.sources.length ? barList(d.sources, 'screenPageViews') : '<div class="note">No source data.</div>'}</div>
          <div><h3>Reader countries</h3>${d.countries.length ? barList(d.countries, 'screenPageViews') : '<div class="note">No country data.</div>'}</div>
          <div><h3>Devices</h3>${d.devices?.length ? barList(d.devices, 'screenPageViews') : '<div class="note">No device data.</div>'}</div>
        </div>`;
      trendChart(
        document.getElementById('pd-trend'), d.trend, 'screenPageViews', 'Page views', 170,
        ec?.date ? [{ date: ec.date, label: `📧 ${ec.name} — ${fmtInt(ec.sent)} recipients` }] : []
      );
    } catch (err) {
      body.innerHTML = `<h3>Page — <span class="crumb">${esc(path)}</span></h3><div class="err" style="padding:8px 0">${esc(err.message)}</div>`;
    }
  }

  // Two-letter initials for the sidebar chips.
  function initials(name) {
    const words = String(name).split(/\s+/).filter((w) => /[a-z0-9]/i.test(w));
    return ((words[0]?.[0] || '') + (words[1]?.[0] || words[0]?.[1] || '')).toUpperCase();
  }

  // Live-site base URL for a site id (for click-through ↗ links).
  function siteUrlFor(siteId) {
    const u = (state.config?.sites || []).find((s) => s.id === siteId)?.url || '';
    return u.replace(/\/+$/, '');
  }

  // Top-pages card with drill-down: click a path to list the pages under it.
  // Replace bare /releases/1234 labels with real titles from the reports DB.
  // Works on any container with [data-path] bar rows; silently does nothing
  // when the site has no DB connected.
  async function enrichPathsWithTitles(container, siteId) {
    const siteCfg = (state.config?.sites || []).find((x) => x.id === siteId);
    if (!siteCfg?.kind) return;
    const rows = [...container.querySelectorAll('[data-path]')];
    const idOf = (p) => /(\d{3,})\/?$/.exec(String(p))?.[1] || null;
    const ids = [...new Set(rows.map((r) => idOf(r.dataset.path)).filter(Boolean))];
    if (!ids.length) return;
    try {
      const d = await api(`/api/site/${encodeURIComponent(siteId)}/release-titles?ids=${ids.join(',')}`);
      if (!d.titles) return;
      rows.forEach((row) => {
        const rid = idOf(row.dataset.path);
        const t = rid && d.titles[rid];
        if (!t?.title) return;
        const lbl = row.querySelector('.bar-label');
        if (lbl) {
          lbl.innerHTML = `${esc(t.title)}<span class="note" style="font-weight:400"> · ${esc(row.dataset.path)}</span>`;
          lbl.title = `${t.title} (${row.dataset.path})`;
        }
      });
    } catch { /* cosmetic only */ }
  }

  function renderPagesCard(siteId, rows, prefix, homeRows) {
    const el = document.getElementById('pages-card');
    if (!el) return;
    const base = siteUrlFor(siteId);
    const rowsHtml = (list) => {
      const max = Math.max(1, ...list.map((r) => r.screenPageViews || 0));
      return list.map((r) => `
        <div class="bar-row clickable linked" data-path="${esc(r.dimensionValue)}" title="Click to see pages under ${esc(r.dimensionValue)}">
          <div class="bar-label">${esc(r.dimensionValue)}</div>
          <div class="bar-track"><div class="bar-fill" style="width:${(100 * (r.screenPageViews || 0)) / max}%"></div></div>
          <div class="bar-value">${fmtInt(r.screenPageViews)}</div>
          ${base ? `<a class="ext" href="${esc(base + r.dimensionValue)}" target="_blank" rel="noopener" title="Open ${esc(r.dimensionValue)} on the live site">↗</a>` : '<span></span>'}
        </div>`).join('');
    };
    el.innerHTML = `
      <div class="card-head">
        <h3>Top pages${prefix ? ` — <span class="crumb">${esc(prefix === '/' ? '/' : prefix + '/…')}</span>` : ''}</h3>
        <input class="card-search" id="pages-search" placeholder="Filter paths…" />
      </div>
      ${prefix ? '<button class="back-link" id="pages-back">← All pages</button>' : ''}
      <div id="pages-list">${rows.length ? rowsHtml(rows) : '<div class="note">No pages under this path in this period.</div>'}</div>
      <div class="note" style="margin-top:8px">${prefix ? 'Page views under ' + esc(prefix) + ' — click a row to go deeper' : 'Page views by path — click a row to drill down'}</div>`;
    const back = el.querySelector('#pages-back');
    if (back) back.addEventListener('click', () => renderPagesCard(siteId, homeRows, null, homeRows));
    enrichPathsWithTitles(el, siteId);
    const bindRows = () => {
      el.querySelectorAll('a.ext').forEach((a) => a.addEventListener('click', (e) => e.stopPropagation()));
      el.querySelectorAll('.bar-row.clickable').forEach((row) => row.addEventListener('click', () => onRowClick(row)));
    };
    const searchInput = el.querySelector('#pages-search');
    searchInput.addEventListener('input', () => {
      const q = searchInput.value.toLowerCase();
      const filtered = rows.filter((r) => r.dimensionValue.toLowerCase().includes(q));
      const listEl = el.querySelector('#pages-list');
      listEl.innerHTML = filtered.length ? rowsHtml(filtered) : '<div class="note">Nothing in the top pages — searching all of GA4…</div>';
      bindRows();
      enrichPathsWithTitles(el, siteId);
      if (!filtered.length && q.trim().length >= 2) ga4Search(siteId, q.trim(), listEl);
    });
    const onRowClick = async (row) => {
        const path = row.dataset.path;
        el.querySelector('h3').innerHTML = `Top pages — <span class="crumb">${esc(path === '/' ? '/' : path + '/…')}</span> <span class="note">loading…</span>`;
        try {
          const resp = await api(`/api/site/${encodeURIComponent(siteId)}/pages?prefix=${encodeURIComponent(path)}&range=${encodeURIComponent(state.range)}`);
          if (resp.rows.length === 0 && path !== '/') {
            // No sub-pages — it's a single page: open its dossier in a popup.
            el.querySelector('h3').innerHTML = `Top pages${prefix ? ' — <span class="crumb">' + esc(prefix === '/' ? '/' : prefix + '/…') + '</span>' : ''}`;
            renderPageDetail(siteId, path);
            return;
          }
          renderPagesCard(siteId, resp.rows, path, homeRows);
        } catch (err) {
          if (err.message !== 'unauthorised')
            el.querySelector('h3').innerHTML = `Top pages <span class="note">— couldn't load ${esc(path)}: ${esc(err.message)}</span>`;
        }
    };
    bindRows();
  }

  // ── chat ─────────────────────────────────────────────────────────────
  const SUGGESTIONS = [
    'Top 100 journalists — last 28 days, with publications',
    'Who clicked our email campaigns in the last 28 days?',
    'Was this month\'s traffic change content-led?',
    'Write a monthly report for all sites',
    'Which site grew the most this month?',
  ];

  function renderChat() {
    const slot = document.getElementById('chat-slot');
    if (!slot) return;
    if (!state.chatOpen) { slot.innerHTML = ''; toggleChatButton(); return; }
    slot.innerHTML = `
      <div class="chat-overlay" id="chat-overlay">
        <div class="chat-modal" role="dialog" aria-modal="true" aria-label="AI analyst">
          <div class="chat-head">
            <b>✦ AI analyst</b>
            <div class="chat-head-actions">
              <button id="chat-clear" class="chat-clear" title="Clear the conversation">Clear chat</button>
              <button id="chat-close" title="Close (Esc)">✕</button>
            </div>
          </div>
          <div class="chat-body" id="chat-body"></div>
          <div class="chat-suggest" id="chat-suggest"></div>
          <div class="chat-input">
            <textarea id="chat-text" placeholder="Ask across GA4, the reports database and email — releases, journalists, campaigns, traffic…"></textarea>
            <button id="chat-send">Send</button>
          </div>
        </div>
      </div>`;
    const close = () => { state.chatOpen = false; renderChat(); };
    document.getElementById('chat-close').addEventListener('click', close);
    document.getElementById('chat-overlay').addEventListener('mousedown', (e) => {
      if (e.target === e.currentTarget) close();
    });
    document.getElementById('chat-clear').addEventListener('click', () => {
      if (state.chatBusy) return;
      state.chatMsgs = [];
      updateChatBody();
      document.getElementById('chat-text')?.focus();
    });
    document.getElementById('chat-send').addEventListener('click', () => sendChat());
    document.getElementById('chat-text').addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
    });
    document.getElementById('chat-text').focus();
    updateChatBody();
    toggleChatButton();
  }

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && state.chatOpen) { state.chatOpen = false; renderChat(); }
  });

  function toggleChatButton() {
    const btn = document.getElementById('chat-open');
    if (btn) btn.style.display = state.chatOpen ? 'none' : '';
  }

  function updateChatBody() {
    const body = document.getElementById('chat-body');
    const sug = document.getElementById('chat-suggest');
    if (!body) return;
    let html = '';
    if (!state.chatMsgs.length) {
      html += `<div class="msg assistant"><p>Ask me anything across <b>all your data</b> — GA4 traffic, the reports database (releases, journalists, publications, clients) and email campaigns. I can rank your top journalists, pull everyone who clicked a campaign, join a release's platform + email + web numbers, or write a full report.</p>${
        state.config?.chatConfigured ? '' : '<p><i>Heads-up: no OpenAI API key is configured yet — set <code>OPENAI_API_KEY</code> in <code>.env</code> to enable answers.</i></p>'}</div>`;
    }
    html += state.chatMsgs.map((m) =>
      m.role === 'user'
        ? `<div class="msg user">${esc(m.content)}</div>`
        : `<div class="msg assistant">${md(m.content)}</div>`
    ).join('');
    if (state.chatBusy) {
      const p = state.chatProgress || { label: 'Working…', pct: 5, steps: [] };
      html += `<div class="chat-prog">
        ${(p.steps || []).map((s2) => `<div class="chat-prog-done">✓ ${esc(s2)}</div>`).join('')}
        <div class="chat-prog-now"><span class="chat-spin"></span> ${esc(p.label)} <span class="chat-prog-pct">${Math.round(p.pct || 0)}%</span></div>
        <div class="chat-prog-track"><div class="chat-prog-fill" style="width:${Math.max(2, Math.min(100, p.pct || 0))}%"></div></div>
      </div>`;
    }
    body.innerHTML = html;
    body.scrollTop = body.scrollHeight;
    if (sug) {
      sug.innerHTML = state.chatMsgs.length ? '' :
        SUGGESTIONS.map((s) => `<button>${esc(s)}</button>`).join('');
      sug.querySelectorAll('button').forEach((b) => b.addEventListener('click', () => sendChat(b.textContent)));
    }
    const sendBtn = document.getElementById('chat-send');
    if (sendBtn) sendBtn.disabled = state.chatBusy;
  }

  async function sendChat(text) {
    const ta = document.getElementById('chat-text');
    const q = (text ?? ta.value).trim();
    if (!q || state.chatBusy) return;
    ta.value = '';
    state.chatMsgs.push({ role: 'user', content: q });
    state.chatBusy = true;
    state.chatProgress = { label: 'Starting…', pct: 3, steps: [] };
    updateChatBody();
    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          messages: state.chatMsgs.map(({ role, content }) => ({ role, content })),
          context: { siteId: route().id || null, range: state.range,
            gm: state.gaMarketMode?.[route().id] === 'locale' ? null : (state.gaMarket?.[route().id] || null),
            gml: state.gaMarketMode?.[route().id] === 'locale' ? (state.gaMarket?.[route().id] || null) : null },
        }),
      });
      const ctype = res.headers.get('content-type') || '';
      if (!res.ok || ctype.includes('application/json')) {
        // non-stream error (401/403/etc.)
        const d = await res.json().catch(() => ({}));
        throw new Error(d.error || `Request failed (${res.status})`);
      }
      // NDJSON progress stream
      const reader = res.body.getReader();
      const dec = new TextDecoder();
      let buf = '', gotReply = false;
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        buf += dec.decode(value, { stream: true });
        let nl;
        while ((nl = buf.indexOf('\n')) >= 0) {
          const line = buf.slice(0, nl).trim();
          buf = buf.slice(nl + 1);
          if (!line) continue;
          let ev;
          try { ev = JSON.parse(line); } catch { continue; }
          if (ev.type === 'done') {
            state.chatMsgs.push(ev.reply);
            gotReply = true;
          } else if (ev.type === 'error') {
            throw new Error(ev.error);
          } else {
            const p = state.chatProgress || { steps: [] };
            if (ev.type === 'tool' && p.label && p.label !== ev.label) p.steps = [...(p.steps || []), p.label].slice(-4);
            state.chatProgress = { label: ev.label, pct: ev.pct || p.pct, steps: p.steps || [] };
            updateChatBody();
          }
        }
      }
      if (!gotReply) throw new Error('The connection dropped before the answer arrived — please try again.');
    } catch (err) {
      state.chatMsgs.push({ role: 'assistant', content: 'Something went wrong: ' + err.message });
    } finally {
      state.chatBusy = false;
      state.chatProgress = null;
      updateChatBody();
    }
  }

  // ── boot ─────────────────────────────────────────────────────────────
  // ── auth screens (login / register / first-run setup) ────────────────
  function authShell(inner) {
    root.innerHTML = `
      <div class="login-wrap">
        <div class="card" style="width:360px">
          <div class="brand" style="border:0;padding:0 0 14px"><span class="logo-mark">${LOGO}</span><div>Spark<span style="color:var(--series-1)">Software</span><small>Reporting</small></div></div>
          ${inner}
        </div>
      </div>`;
  }

  function renderAuth(mode, msg) {
    if (mode === 'setup') {
      authShell(`
        <h3 style="margin:0 0 4px">Welcome — create your super admin</h3>
        <p class="note" style="margin:0 0 12px">This first account owns the platform: it manages users, roles and sites.</p>
        <form id="auth-form" class="auth-form">
          <input class="auth-in" name="name" placeholder="Your name" required />
          <input class="auth-in" name="email" type="email" placeholder="Email" required />
          <input class="auth-in" name="password" type="password" placeholder="Password (min 8 characters)" required minlength="8" />
          <button class="btn-primary">Create account & sign in</button>
        </form>
        <div class="err auth-err" style="display:none"></div>`);
      bindAuthForm('/api/auth/setup');
      return;
    }
    if (mode === 'register') {
      authShell(`
        <h3 style="margin:0 0 4px">Request access</h3>
        <p class="note" style="margin:0 0 12px">Your account will be reviewed and approved by an administrator.</p>
        <form id="auth-form" class="auth-form">
          <input class="auth-in" name="name" placeholder="Your name" required />
          <input class="auth-in" name="email" type="email" placeholder="Work email" required />
          <input class="auth-in" name="password" type="password" placeholder="Choose a password (min 8)" required minlength="8" />
          <button class="btn-primary">Request access</button>
        </form>
        <div class="err auth-err" style="display:none"></div>
        <p class="note" style="margin-top:12px">Already have an account? <a href="#" id="auth-alt" style="color:var(--accent);font-weight:650">Sign in</a></p>`);
      bindAuthForm('/api/auth/register', true);
      document.getElementById('auth-alt').addEventListener('click', (e) => { e.preventDefault(); renderAuth('login'); });
      return;
    }
    authShell(`
      <h3 style="margin:0 0 4px">Sign in</h3>
      ${msg ? `<p class="note" style="margin:0 0 12px;color:var(--good-text)">${esc(msg)}</p>` : '<p class="note" style="margin:0 0 12px">Welcome back.</p>'}
      <form id="auth-form" class="auth-form">
        <input class="auth-in" name="email" type="email" placeholder="Email" required autofocus />
        <input class="auth-in" name="password" type="password" placeholder="Password" required />
        <button class="btn-primary">Sign in</button>
      </form>
      <div class="err auth-err" style="display:none"></div>
      <p class="note" style="margin-top:12px">Need an account? <a href="#" id="auth-alt" style="color:var(--accent);font-weight:650">Request access</a></p>`);
    bindAuthForm('/api/auth/login');
    document.getElementById('auth-alt').addEventListener('click', (e) => { e.preventDefault(); renderAuth('register'); });
  }

  function bindAuthForm(endpoint, isRegister = false) {
    const form = document.getElementById('auth-form');
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      const errEl = document.querySelector('.auth-err');
      errEl.style.display = 'none';
      const data = Object.fromEntries(new FormData(form).entries());
      try {
        const res = await fetch(endpoint, {
          method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
        });
        const out = await res.json();
        if (!res.ok) throw new Error(out.error || 'Something went wrong.');
        if (isRegister) { renderAuth('login', out.message || 'Account created — awaiting approval.'); return; }
        boot();
      } catch (err) {
        errEl.textContent = err.message; errEl.style.display = 'block';
      }
    });
  }

  // PWA: register the service worker (works on https and on localhost).
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/sw.js').catch(() => { /* http on a LAN IP — fine, app still works */ });
    });
  }

  async function boot() {
    if (state.portal) {
      // Remembered password for this link (per browser session)
      try { state.portal.pw = sessionStorage.getItem('sharePw:' + state.portal.siteId) || null; } catch { /* private mode */ }
      const tryPortal = async () => {
        const pwQ = state.portal.pw ? `&pw=${encodeURIComponent(state.portal.pw)}` : '';
        const r = await fetch(`/api/portal?site=${encodeURIComponent(state.portal.siteId)}&token=${encodeURIComponent(state.portal.token)}${pwQ}`);
        const d = await r.json().catch(() => ({}));
        if (!r.ok) {
          if (d.needPassword) return 'password';
          throw new Error('This share link is not valid.');
        }
        state.portal.site = d.site;
        // Shared links get the SAME AI analysis as the dashboard — the
        // narrative buttons/cards only hide when no AI key is configured.
        state.config = { sites: [d.site], chatConfigured: d.chatConfigured !== false };
        renderPortalShell();
        return 'ok';
      };
      const askPassword = (wrong) => {
        root.innerHTML = `<div class="login-wrap"><div class="card" style="max-width:360px">
          <h3>Spark<span style="color:var(--series-1)">Software</span> Reporting</h3>
          <p class="note" style="margin:6px 0 10px">This client link is password-protected.</p>
          ${wrong ? '<p class="err" style="padding:2px 0 8px">That password is not right — try again.</p>' : ''}
          <form id="pw-form" style="display:grid;gap:8px">
            <input class="auth-in" name="pw" type="password" placeholder="Password" required autofocus />
            <button class="btn-primary">View the report</button>
          </form></div></div>`;
        root.querySelector('#pw-form').addEventListener('submit', async (e) => {
          e.preventDefault();
          state.portal.pw = new FormData(e.target).get('pw');
          try { sessionStorage.setItem('sharePw:' + state.portal.siteId, state.portal.pw); } catch { /* noop */ }
          try {
            if ((await tryPortal()) === 'password') askPassword(true);
          } catch (err) { root.innerHTML = `<div class="login-wrap"><div class="card"><p class="err">${esc(err.message)}</p></div></div>`; }
        });
      };
      try {
        if ((await tryPortal()) === 'password') askPassword(false);
      } catch (err) {
        root.innerHTML = `<div class="login-wrap"><div class="card"><h3>Spark<span style="color:var(--series-1)">Software</span> Reporting</h3><p class="err" style="padding:6px 0">${esc(err.message)}</p></div></div>`;
      }
      return;
    }
    try {
      // Licence check first — a locked install shows the activation screen
      // straight away, before any login.
      try {
        const licR = await fetch('/api/license');
        const licD = await licR.json();
        if (licD?.license?.blocked) { renderLicenseLock(licD.license); return; }
      } catch { /* server unreachable → normal error path below */ }
      const me = await fetch('/api/auth/me');
      if (me.status === 401) {
        const d = await me.json();
        renderAuth(d.needsSetup ? 'setup' : 'login');
        return;
      }
      state.config = await api('/api/config');
      state.user = state.config.user;
      renderShell();
    } catch (err) {
      root.innerHTML = `<div class="err">Can't reach the server: ${esc(err.message)}</div>`;
    }
  }

  async function logout() {
    await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
    state.config = null; state.user = null; state.chatMsgs = [];
    renderAuth('login');
  }

  // ── graph downloads: any card → crisp PNG, no libraries ───────────────
  // Clone the card, inline every computed style (this also resolves CSS
  // variables), serialise into an SVG foreignObject, draw to a canvas.
  function inlineStyles(src, dst) {
    if (src.nodeType !== 1) return;
    const cs = getComputedStyle(src);
    let txt = '';
    for (let i = 0; i < cs.length; i++) {
      const p = cs[i];
      txt += `${p}:${cs.getPropertyValue(p)};`;
    }
    dst.setAttribute('style', txt);
    for (let i = 0; i < src.children.length; i++) {
      if (dst.children[i]) inlineStyles(src.children[i], dst.children[i]);
    }
  }

  // Rebuild a card as a standalone SVG from its LIVE geometry: every text run,
  // bar, pill and table line is drawn at its real on-screen position, and
  // inline chart <svg>s are embedded verbatim (with CSS variables resolved).
  // No foreignObject → the canvas stays untainted and PNG export always works.
  const svgEsc2 = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  function cardToSvgString(card) {
    const rootRect = card.getBoundingClientRect();
    const W = Math.ceil(rootRect.width), H = Math.ceil(rootRect.height);
    const rel = (r) => ({ x: +(r.left - rootRect.left).toFixed(1), y: +(r.top - rootRect.top).toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1) });
    const parts = [];
    let maxX = W; // grows when content (wide tables) extends past the card edge
    const isClear = (c) => !c || c === 'transparent' || /^rgba\(\s*\d+,\s*\d+,\s*\d+,\s*0\)/.test(c);
    const cardCs = getComputedStyle(card);
    // The right-most pixel any text may reach: the nearest ancestor that CLIPS
    // (overflow hidden/clip — bar labels etc.). Checking only the direct
    // parent missed labels whose text node sits after a <b> or inside a
    // <span>, which is exactly how text ended up running UNDER the bars in
    // exported images. overflow:auto (scrollable tables) does NOT clip here —
    // that content is drawn in full and the canvas widens to fit it.
    const clipRight = (elx) => {
      let right = null;
      for (let p = elx; p && p !== card.parentElement; p = p.parentElement) {
        const pcs = getComputedStyle(p);
        if (/(hidden|clip)/.test(pcs.overflowX || '') || pcs.overflow === 'hidden') {
          const rr = p.getBoundingClientRect().right;
          right = right == null ? rr : Math.min(right, rr);
        }
      }
      return right;
    };
    const measurer = document.createElement('canvas').getContext('2d');
    const drawText = (node, parentEl) => {
      const raw = node.textContent;
      if (!raw.trim()) return;
      const range = document.createRange();
      range.selectNodeContents(node);
      let rects = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5);
      if (!rects.length) return;
      // Chrome reports DUPLICATE/overlapping rects for a clipped nowrap text
      // node (full extent + clipped fragment). All rects on one visual line =
      // one line — merge them, or the wrapped-text path below draws the full
      // text with no truncation and it runs under the bars.
      const tops = [...new Set(rects.map((r) => Math.round(r.top / 3)))];
      if (tops.length === 1 && rects.length > 1) {
        const left = Math.min(...rects.map((r) => r.left));
        const right = Math.max(...rects.map((r) => r.right));
        rects = [{ left, right, top: rects[0].top, bottom: rects[0].bottom, width: right - left, height: rects[0].height }];
      }
      const cs = getComputedStyle(parentEl);
      const fs = parseFloat(cs.fontSize) || 13;
      const common = `font-family="Inter, -apple-system, sans-serif" font-size="${fs}" font-weight="${cs.fontWeight}" fill="${cs.color}"`;
      if (rects.length === 1) {
        const r = rel(rects[0]);
        let s = raw.replace(/\s+/g, ' ').trim();
        // Truncate at the clipping ancestor's edge — by RIGHT EDGE, not width,
        // so text starting after a bold prefix still respects the column.
        // Measured with the actual font, so the ellipsis lands cleanly BEFORE
        // the bar instead of running underneath it.
        const cr = clipRight(parentEl);
        if (cr != null && rects[0].right > cr + 2) {
          const avail = cr - rects[0].left - 4;
          if (avail < fs) return; // no room at all — draw nothing
          measurer.font = `${cs.fontWeight} ${fs}px Inter, -apple-system, sans-serif`;
          let lo = 0, hi = s.length;
          while (lo < hi) { // largest prefix that fits with an ellipsis
            const mid = Math.ceil((lo + hi) / 2);
            if (measurer.measureText(s.slice(0, mid) + '…').width <= avail) lo = mid; else hi = mid - 1;
          }
          if (lo < 2) return;
          s = s.slice(0, lo).replace(/\s+$/, '') + '…';
        } else {
          maxX = Math.max(maxX, r.x + r.w);
        }
        parts.push(`<text x="${r.x}" y="${(r.y + r.h * 0.78).toFixed(1)}" ${common}>${svgEsc2(s)}</text>`);
        return;
      }
      // Wrapped text: group characters into visual lines (short notes only)
      const lines = [];
      let cur = null;
      for (let i = 0; i < raw.length; i++) {
        const cr = document.createRange();
        cr.setStart(node, i); cr.setEnd(node, i + 1);
        const rr = cr.getClientRects()[0];
        if (!rr || rr.width === 0) { if (cur) cur.text += raw[i]; continue; }
        if (!cur || Math.abs(rr.top - cur.top) > 2) { cur = { top: rr.top, left: rr.left, h: rr.height, text: raw[i] }; lines.push(cur); }
        else cur.text += raw[i];
      }
      const cs2 = getComputedStyle(parentEl);
      lines.forEach((L) => {
        const x = +(L.left - rootRect.left).toFixed(1), y = +(L.top - rootRect.top + L.h * 0.78).toFixed(1);
        parts.push(`<text x="${x}" y="${y}" font-family="Inter, -apple-system, sans-serif" font-size="${parseFloat(cs2.fontSize) || 13}" font-weight="${cs2.fontWeight}" fill="${cs2.color}">${svgEsc2(L.text.replace(/\s+/g, ' '))}</text>`);
      });
    };
    const gradFirst = (bg) => (/(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\))/.exec(bg || '') || [])[1] || null;
    const walk = (el) => {
      for (const node of el.childNodes) {
        if (node.nodeType === 3) { drawText(node, el); continue; }
        if (node.nodeType !== 1) continue;
        if (node.matches('button, input, select, textarea, .card-dl, .chart-tip, .metric-toggle, .card-search, .list-q, a.ext')) continue;
        const cs = getComputedStyle(node);
        if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) continue;
        if (node.tagName.toLowerCase() === 'svg') {
          const r = rel(node.getBoundingClientRect());
          const clone = node.cloneNode(true);
          inlineStyles(node, clone); // resolves var(--…) into literal values
          clone.setAttribute('x', r.x); clone.setAttribute('y', r.y);
          clone.setAttribute('width', r.w); clone.setAttribute('height', r.h);
          parts.push(new XMLSerializer().serializeToString(clone));
          continue;
        }
        const r = rel(node.getBoundingClientRect());
        if (r.w > 0.5 && r.h > 0.5) {
          if (clipRight(node.parentElement || card) == null) maxX = Math.max(maxX, r.x + r.w);
          let fill = !isClear(cs.backgroundColor) ? cs.backgroundColor
            : cs.backgroundImage && cs.backgroundImage.includes('gradient') ? gradFirst(cs.backgroundImage) : null;
          if (fill) {
            parts.push(`<rect x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(parseFloat(cs.borderRadius) || 0, r.h / 2)}" fill="${fill}"/>`);
          }
          // Borders per side (top border = table row separators, etc.)
          const sides = [['Top', `x1="${r.x}" y1="${r.y}" x2="${r.x + r.w}" y2="${r.y}"`], ['Bottom', `x1="${r.x}" y1="${r.y + r.h}" x2="${r.x + r.w}" y2="${r.y + r.h}"`], ['Left', `x1="${r.x}" y1="${r.y}" x2="${r.x}" y2="${r.y + r.h}"`], ['Right', `x1="${r.x + r.w}" y1="${r.y}" x2="${r.x + r.w}" y2="${r.y + r.h}"`]];
          for (const [side, coords] of sides) {
            const bw = parseFloat(cs['border' + side + 'Width']) || 0;
            const bcol = cs['border' + side + 'Color'];
            if (bw > 0 && cs['border' + side + 'Style'] === 'solid' && !isClear(bcol) && !fill) {
              parts.push(`<line ${coords} stroke="${bcol}" stroke-width="${bw}"/>`);
            }
          }
        }
        walk(node);
      }
    };
    walk(card);
    // Final width: wide tables (Documents, All websites…) extend past the
    // card's on-screen edge — widen the image so every column is visible
    // instead of cutting numbers off at the card boundary.
    const W2 = maxX > W + 1 ? Math.min(Math.ceil(maxX) + 14, W + 1000) : W;
    parts.unshift(`<rect x="0.5" y="0.5" width="${W2 - 1}" height="${H - 1}" rx="${parseFloat(cardCs.borderRadius) || 14}" fill="${cardCs.backgroundColor}" stroke="${isClear(cardCs.borderColor) ? 'none' : cardCs.borderColor}"/>`);
    return { svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${W2}" height="${H}" viewBox="0 0 ${W2} ${H}">${parts.join('')}</svg>`, W: W2, H };
  }

  async function cardToPng(card, scale = 2) {
    const { svg, W, H } = cardToSvgString(card);
    const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
    try {
      const img = new Image();
      await new Promise((res, rej) => { img.onload = res; img.onerror = () => rej(new Error('render failed')); img.src = url; });
      const canvas = document.createElement('canvas');
      canvas.width = W * scale; canvas.height = H * scale;
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = getComputedStyle(document.body).backgroundColor || '#fff';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.scale(scale, scale);
      ctx.drawImage(img, 0, 0);
      return await new Promise((res, rej) => canvas.toBlob((b) => (b ? res(b) : rej(new Error('png failed'))), 'image/png'));
    } finally { URL.revokeObjectURL(url); }
  }

  const slugify = (s) => String(s || 'graph').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'graph';

  function saveBlob(blob, filename) {
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    a.click();
    setTimeout(() => URL.revokeObjectURL(a.href), 5000);
  }

  const cardTitle = (card) =>
    card.classList?.contains('tiles') ? 'Key metrics'
      : (card.querySelector('h3')?.innerText || card.innerText.split('\n').find((l) => l.trim()) || 'graph').trim();

  async function downloadCardPng(card) {
    const blob = await cardToPng(card);
    saveBlob(blob, `${slugify(cardTitle(card))}-${new Date().toISOString().slice(0, 10)}.png`);
  }

  // Minimal ZIP writer (store method) — bundles all graph PNGs into one file
  // without any compression work, so memory stays low and it's instant.
  const CRC_TABLE = (() => {
    const t = new Uint32Array(256);
    for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
    return t;
  })();
  function crc32(buf) {
    let c = 0xffffffff;
    for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  }
  function makeStoreZip(files) {
    const enc = new TextEncoder();
    const u16 = (v) => [v & 255, (v >> 8) & 255];
    const u32 = (v) => [v & 255, (v >> 8) & 255, (v >> 16) & 255, (v >>> 24) & 255];
    const chunks = [], central = [];
    let offset = 0;
    for (const f of files) {
      const nameB = enc.encode(f.name);
      const crc = crc32(f.data);
      const head = new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...u16(20), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
        ...u32(crc), ...u32(f.data.length), ...u32(f.data.length), ...u16(nameB.length), ...u16(0)]);
      chunks.push(head, nameB, f.data);
      central.push({ nameB, crc, size: f.data.length, offset });
      offset += head.length + nameB.length + f.data.length;
    }
    const cdStart = offset;
    let cdSize = 0;
    for (const c of central) {
      const rec = new Uint8Array([0x50, 0x4b, 0x01, 0x02, ...u16(20), ...u16(20), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
        ...u32(c.crc), ...u32(c.size), ...u32(c.size), ...u16(c.nameB.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(c.offset)]);
      chunks.push(rec, c.nameB);
      cdSize += rec.length + c.nameB.length;
    }
    chunks.push(new Uint8Array([0x50, 0x4b, 0x05, 0x06, ...u16(0), ...u16(0), ...u16(central.length), ...u16(central.length), ...u32(cdSize), ...u32(cdStart), ...u16(0)]));
    const out = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0));
    let p = 0;
    for (const c of chunks) { out.set(c, p); p += c.length; }
    return out;
  }

  function collectDlCards() {
    // KPI tiles export as ONE blended "Key metrics" image (their .grid.tiles
    // container), never as seven separate tiny files.
    const out = [];
    const seenGrids = new Set();
    for (const c of document.querySelectorAll('#content .card')) {
      if (c.offsetParent === null || c.innerText.trim().length <= 10) continue;
      if (c.classList.contains('tile')) {
        const grid = c.closest('.grid.tiles');
        if (grid) { if (!seenGrids.has(grid)) { seenGrids.add(grid); out.push(grid); } continue; }
      }
      out.push(c);
    }
    return out;
  }

  // Picker: choose exactly which graphs to download (GA, platform AND media
  // monitoring cards all appear), or grab everything in one zip.
  function openGraphPicker() {
    const cards = collectDlCards();
    if (!cards.length) return;
    const body = openModal(`<h3>Download graphs</h3>
      <div class="note" style="margin-bottom:8px">Tick the graphs you want — they download together as a zip of images. Media-monitoring graphs are included in the list.</div>
      <div style="display:flex;gap:8px;margin-bottom:8px">
        <button class="mini-btn" id="gp-all">Select all</button>
        <button class="mini-btn" id="gp-none">Select none</button>
      </div>
      <div style="max-height:340px;overflow:auto;border:1px solid var(--border);border-radius:10px;padding:8px 10px">
        ${cards.map((c, i) => `<label class="mkt-opt"><input type="checkbox" class="gp-cb" value="${i}" checked/> ${esc(cardTitle(c) || 'Graph ' + (i + 1))}</label>`).join('')}
      </div>
      <button class="btn-primary" id="gp-go" style="margin-top:12px;width:100%">⬇ Download selected</button>`);
    body.querySelector('#gp-all').addEventListener('click', () => body.querySelectorAll('.gp-cb').forEach((c) => (c.checked = true)));
    body.querySelector('#gp-none').addEventListener('click', () => body.querySelectorAll('.gp-cb').forEach((c) => (c.checked = false)));
    body.querySelector('#gp-go').addEventListener('click', async () => {
      const idx = [...body.querySelectorAll('.gp-cb')].filter((c) => c.checked).map((c) => Number(c.value));
      if (!idx.length) return;
      const goBtn = body.querySelector('#gp-go');
      goBtn.disabled = true;
      await downloadAllGraphs(null, idx.map((i) => cards[i]), (done, total) => { goBtn.textContent = `⬇ Rendering ${done}/${total}…`; });
      closeModal();
    });
  }

  async function downloadAllGraphs(btn, cardsArg = null, progress = null) {
    const cards = cardsArg || collectDlCards();
    if (!cards.length) return;
    const files = [];
    const seen = {};
    for (let i = 0; i < cards.length; i++) {
      if (btn) btn.textContent = `⬇ ${i + 1}/${cards.length}…`;
      if (progress) progress(i + 1, cards.length);
      let name = slugify(cardTitle(cards[i]) || `graph-${i + 1}`);
      seen[name] = (seen[name] || 0) + 1;
      if (seen[name] > 1) name += '-' + seen[name];
      try {
        const blob = await cardToPng(cards[i]); // sequential — one canvas at a time
        files.push({ name: `${String(i + 1).padStart(2, '0')}-${name}.png`, data: new Uint8Array(await blob.arrayBuffer()) });
      } catch { /* skip cards that fail to render */ }
    }
    if (files.length) {
      const scope = route().id || 'overview';
      saveBlob(new Blob([makeStoreZip(files)], { type: 'application/zip' }), `graphs-${scope}-${new Date().toISOString().slice(0, 10)}.zip`);
    }
    if (btn) btn.textContent = '⬇ Graphs';
  }

  // Put a small ⬇ on every dashboard card as content renders.
  function ensureDlButtons() {
    document.querySelectorAll('#content .card, #content .grid.tiles').forEach((card) => {
      // Individual KPI tiles don't get their own button — the tiles GRID gets
      // one, exporting all the key metrics blended into a single image.
      if (card.classList.contains('tile')) return;
      if (card.classList.contains('tiles')) card.style.position = 'relative';
      if (card.querySelector(':scope > .card-dl')) return;
      const b = document.createElement('button');
      b.className = 'card-dl';
      b.title = 'Download this graph as an image';
      b.textContent = '⬇';
      b.addEventListener('click', async (e) => {
        e.stopPropagation();
        b.textContent = '…';
        try { await downloadCardPng(card); b.textContent = '✓'; }
        catch { b.textContent = '✗'; }
        setTimeout(() => { b.textContent = '⬇'; }, 1600);
      });
      card.appendChild(b);
    });
  }
  new MutationObserver(() => ensureDlButtons()).observe(document.body, { childList: true, subtree: true });

  window.addEventListener('hashchange', () => renderShell());
  window.__lastW = window.innerWidth;
  window.addEventListener('resize', () => {
    // Re-render charts to fit — but only when the WIDTH really changed.
    // Mobile scrolling (URL bar) and opening DevTools fire height-only
    // resizes, and each one used to trigger a full dashboard reload.
    clearTimeout(window.__rz);
    window.__rz = setTimeout(() => {
      const w = window.innerWidth;
      if (Math.abs(w - (window.__lastW || 0)) < 40) return;
      window.__lastW = w;
      loadContent();
    }, 200);
  });
  boot();
})();
