← back to Ga Global Viewer

public/index.html

1097 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GA Global Viewer — Steve Abrams Fleet</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<style>
  :root {
    --bg: #0f1117;
    --surface: #1a1d27;
    --surface2: #22263a;
    --border: #2a2f45;
    --accent: #6c8cff;
    --accent2: #4fffb0;
    --accent3: #ff7c5c;
    --ink: #e8ecf4;
    --muted: #7a829a;
    --success: #3fd68c;
    --warn: #f5a623;
    --danger: #ff4d4f;
    --radius: 12px;
    --card-shadow: 0 2px 16px rgba(0,0,0,0.35);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    background: var(--bg);
    color: var(--ink);
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    font-size: 14px;
    min-height: 100vh;
  }

  /* ── header ── */
  header {
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    padding: 16px 32px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    position: sticky;
    top: 0;
    z-index: 100;
  }
  header h1 {
    font-size: 18px;
    font-weight: 700;
    letter-spacing: -0.3px;
    color: var(--accent);
  }
  header .subtitle { font-size: 12px; color: var(--muted); margin-top: 2px; }
  .header-actions { display: flex; gap: 10px; align-items: center; }
  button {
    padding: 7px 16px;
    border-radius: 8px;
    border: 1px solid var(--border);
    background: var(--surface2);
    color: var(--ink);
    cursor: pointer;
    font-size: 13px;
    transition: background 0.15s, border-color 0.15s;
  }
  button:hover { background: var(--border); border-color: var(--accent); }
  button.primary {
    background: var(--accent);
    color: #fff;
    border-color: var(--accent);
  }
  button.primary:hover { background: #5577ee; }

  /* ── auth banner ── */
  #auth-banner {
    display: none;
    margin: 24px 32px 0;
    background: linear-gradient(135deg, #2a1a0e, #1e1a2a);
    border: 1px solid var(--warn);
    border-radius: var(--radius);
    padding: 20px 24px;
  }
  #auth-banner h2 { color: var(--warn); font-size: 16px; margin-bottom: 8px; }
  #auth-banner p { color: var(--ink); line-height: 1.6; margin-bottom: 6px; }
  #auth-banner code {
    background: rgba(255,255,255,0.08);
    padding: 3px 8px;
    border-radius: 5px;
    font-family: 'Fira Code', 'Menlo', monospace;
    font-size: 12px;
    color: var(--accent2);
  }
  #auth-banner ul { margin: 8px 0 0 18px; line-height: 1.8; color: var(--muted); }

  /* ── main layout ── */
  main { padding: 24px 32px; max-width: 1600px; margin: 0 auto; }

  /* ── summary cards ── */
  .stat-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 14px;
    margin-bottom: 28px;
  }
  .stat-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 18px 20px;
    box-shadow: var(--card-shadow);
    position: relative;
    overflow: hidden;
  }
  .stat-card::before {
    content: '';
    position: absolute;
    top: 0; left: 0; right: 0;
    height: 3px;
    background: var(--accent-bar, var(--accent));
  }
  .stat-card .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; }
  .stat-card .value { font-size: 28px; font-weight: 700; color: var(--ink); line-height: 1; }
  .stat-card .sub { font-size: 11px; color: var(--muted); margin-top: 4px; }

  /* ── section headers ── */
  .section-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 14px;
  }
  .section-header h2 {
    font-size: 15px;
    font-weight: 600;
    color: var(--ink);
    display: flex;
    align-items: center;
    gap: 8px;
  }
  .section-header h2 .badge {
    font-size: 10px;
    background: var(--surface2);
    border: 1px solid var(--border);
    padding: 2px 8px;
    border-radius: 20px;
    color: var(--muted);
    font-weight: 500;
  }
  .range-pill {
    font-size: 11px;
    background: var(--surface2);
    border: 1px solid var(--border);
    padding: 4px 12px;
    border-radius: 20px;
    color: var(--accent);
  }

  /* ── chart cards ── */
  .chart-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 20px 24px;
    box-shadow: var(--card-shadow);
    margin-bottom: 20px;
  }
  .chart-container { position: relative; height: 280px; }
  .chart-container.tall { height: 360px; }

  /* ── two-column layout ── */
  .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
  @media (max-width: 900px) { .two-col { grid-template-columns: 1fr; } }

  /* ── property table ── */
  .prop-section { margin-bottom: 20px; }
  .prop-table-wrap {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    overflow: hidden;
    box-shadow: var(--card-shadow);
  }
  table { width: 100%; border-collapse: collapse; }
  th {
    background: var(--surface2);
    padding: 10px 16px;
    text-align: left;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.7px;
    color: var(--muted);
    font-weight: 600;
    position: sticky;
    top: 0;
  }
  td {
    padding: 9px 16px;
    font-size: 13px;
    border-top: 1px solid var(--border);
    vertical-align: middle;
  }
  tr:hover td { background: rgba(108,140,255,0.04); }
  .domain-cell { font-weight: 500; color: var(--ink); }
  .mid-cell { font-family: monospace; font-size: 11px; color: var(--muted); }
  .num { font-variant-numeric: tabular-nums; }
  .bar-cell { width: 120px; }
  .mini-bar-wrap { background: var(--border); border-radius: 3px; height: 6px; overflow: hidden; }
  .mini-bar { height: 6px; background: var(--accent); border-radius: 3px; transition: width 0.3s; }
  .status-ok { color: var(--success); font-size: 11px; }
  .status-err { color: var(--danger); font-size: 11px; }
  .status-no-data { color: var(--muted); font-size: 11px; }

  /* ── sort + density controls (per Steve's CLAUDE.md requirement) ── */
  .table-controls {
    display: flex;
    gap: 12px;
    align-items: center;
    padding: 12px 16px;
    border-bottom: 1px solid var(--border);
    background: var(--surface2);
  }
  .table-controls label { font-size: 12px; color: var(--muted); }
  .table-controls select, .table-controls input[type=range] {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--ink);
    padding: 4px 8px;
    font-size: 12px;
  }
  .table-controls input[type=range] { width: 100px; cursor: pointer; }

  /* ── spinner ── */
  .spinner {
    display: inline-block;
    width: 18px; height: 18px;
    border: 2px solid var(--border);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.7s linear infinite;
    vertical-align: middle;
    margin-right: 6px;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  /* ── loading overlay ── */
  #loading {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 80px 0;
    gap: 16px;
    color: var(--muted);
  }
  #loading .big-spinner {
    width: 40px; height: 40px;
    border: 3px solid var(--border);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.9s linear infinite;
  }

  /* ── tabs ── */
  .tabs { display: flex; gap: 0; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
  .tab {
    padding: 10px 20px;
    cursor: pointer;
    font-size: 13px;
    color: var(--muted);
    border-bottom: 2px solid transparent;
    transition: color 0.15s, border-color 0.15s;
    background: none;
    border-radius: 0;
    border-left: none;
    border-right: none;
    border-top: none;
  }
  .tab:hover { color: var(--ink); }
  .tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }

  .tab-content { display: none; }
  .tab-content.active { display: block; }

  /* ── footer ── */
  footer {
    text-align: center;
    padding: 24px;
    color: var(--muted);
    font-size: 11px;
    border-top: 1px solid var(--border);
    margin-top: 20px;
  }

  /* ── placeholder state ── */
  .placeholder-chart {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 280px;
    color: var(--muted);
    font-size: 13px;
    flex-direction: column;
    gap: 10px;
  }
  .placeholder-chart .icon { font-size: 32px; opacity: 0.3; }

  /* ── refresh log ── */
  #refresh-log {
    display: none;
    background: var(--surface2);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 12px 16px;
    font-family: monospace;
    font-size: 11px;
    color: var(--accent2);
    max-height: 160px;
    overflow-y: auto;
    margin-top: 12px;
    line-height: 1.6;
  }

  /* ── numbers format ── */
  .big-num { font-size: 28px; font-weight: 700; }
</style>
</head>
<body>

<header>
  <div>
    <h1>GA Global Viewer</h1>
    <div class="subtitle">Steve Abrams Fleet &mdash; Google Analytics 4</div>
  </div>
  <div class="header-actions">
    <span id="cache-age" style="font-size:11px;color:var(--muted)"></span>
    <button id="btn-refresh" class="primary" onclick="triggerRefresh()">Refresh Data</button>
  </div>
</header>

<!-- Auth banner (shown when creds are missing) -->
<div id="auth-banner">
  <h2>Credentials Required</h2>
  <p id="auth-error-msg">The service account key is not valid or has been revoked.</p>
  <p style="margin-top:12px;font-weight:600;">To fix this, Steve needs to:</p>
  <ul>
    <li>Go to <strong>console.cloud.google.com</strong> &rarr; IAM &amp; Admin &rarr; Service Accounts</li>
    <li>Project: <code>sheets-updater-485823</code></li>
    <li>Service account: <code>sheets-updater@sheets-updater-485823.iam.gserviceaccount.com</code></li>
    <li>Create a new JSON key and save it to: <code>~/.config/ga-analytics-agent/service-account.json</code></li>
    <li>Or run <code>bash ~/.claude/skills/analytics/scripts/setup.sh</code> to create a fresh service account</li>
    <li>Then click <strong>Refresh Data</strong> above</li>
  </ul>
  <p style="margin-top:12px;color:var(--muted);font-size:12px;">
    The dashboard is fully built and will populate with real data once a valid key is in place.
    All 110 GA4 properties are already mapped (from the cached properties.json) and ready to query.
  </p>
</div>

<main>
  <!-- Loading state -->
  <div id="loading">
    <div class="big-spinner"></div>
    <div>Loading analytics data&hellip;</div>
  </div>

  <!-- Dashboard (hidden until data loads) -->
  <div id="dashboard" style="display:none">

    <!-- Tabs -->
    <div class="tabs">
      <button class="tab active" onclick="switchTab('global')">Global Overview</button>
      <button class="tab" onclick="switchTab('properties')">Per-Property</button>
      <button class="tab" onclick="switchTab('audit')">Fleet Audit</button>
    </div>

    <!-- ── GLOBAL TAB ── -->
    <div id="tab-global" class="tab-content active">

      <!-- Summary stat cards -->
      <div class="stat-grid" id="stat-cards">
        <div class="stat-card" style="--accent-bar: var(--accent)">
          <div class="label">Total Sessions</div>
          <div class="value big-num" id="card-sessions">—</div>
          <div class="sub">Last 30 days, all properties</div>
        </div>
        <div class="stat-card" style="--accent-bar: var(--accent2)">
          <div class="label">Active Users</div>
          <div class="value big-num" id="card-users">—</div>
          <div class="sub">Last 30 days, all properties</div>
        </div>
        <div class="stat-card" style="--accent-bar: var(--accent3)">
          <div class="label">Page Views</div>
          <div class="value big-num" id="card-pageviews">—</div>
          <div class="sub">Last 30 days, all properties</div>
        </div>
        <div class="stat-card" style="--accent-bar: #b06aff">
          <div class="label">Properties Tracked</div>
          <div class="value big-num" id="card-props">—</div>
          <div class="sub" id="card-props-sub">With GA4 installed</div>
        </div>
        <div class="stat-card" style="--accent-bar: var(--warn)">
          <div class="label">Avg Sessions/Site</div>
          <div class="value big-num" id="card-avg">—</div>
          <div class="sub">Per property (last 30d)</div>
        </div>
        <div class="stat-card" style="--accent-bar: var(--success)">
          <div class="label">Pages / Session</div>
          <div class="value big-num" id="card-depth">—</div>
          <div class="sub">Global average</div>
        </div>
      </div>

      <!-- 30-day line chart -->
      <div class="chart-card">
        <div class="section-header">
          <h2>30-Day Traffic Trend <span class="badge">All Properties Combined</span></h2>
          <span class="range-pill" id="date-range-label">30 days</span>
        </div>
        <div class="chart-container tall">
          <canvas id="chart-timeseries"></canvas>
          <div class="placeholder-chart" id="ph-timeseries" style="display:none">
            <div class="icon">&#x1f4c8;</div>
            <div>No time-series data available</div>
          </div>
        </div>
      </div>

      <!-- Bar charts: top pages + top sources -->
      <div class="two-col">
        <div class="chart-card">
          <div class="section-header">
            <h2>Top Pages <span class="badge">Global</span></h2>
          </div>
          <div class="chart-container">
            <canvas id="chart-pages"></canvas>
            <div class="placeholder-chart" id="ph-pages" style="display:none">
              <div class="icon">&#x1f4c4;</div>
              <div>No page data available</div>
            </div>
          </div>
        </div>
        <div class="chart-card">
          <div class="section-header">
            <h2>Top Traffic Sources <span class="badge">Global</span></h2>
          </div>
          <div class="chart-container">
            <canvas id="chart-sources"></canvas>
            <div class="placeholder-chart" id="ph-sources" style="display:none">
              <div class="icon">&#x1f310;</div>
              <div>No source data available</div>
            </div>
          </div>
        </div>
      </div>

    </div><!-- /tab-global -->

    <!-- ── PER-PROPERTY TAB ── -->
    <div id="tab-properties" class="tab-content">

      <div class="prop-section">
        <div class="section-header">
          <h2>Per-Property Breakdown <span class="badge" id="prop-count-badge">0 properties</span></h2>
        </div>

        <div class="prop-table-wrap">
          <!-- Sort + density controls per CLAUDE.md requirement -->
          <div class="table-controls">
            <label>Sort:</label>
            <select id="prop-sort" onchange="renderPropertyTable()">
              <option value="sessions-desc">Sessions (high first)</option>
              <option value="sessions-asc">Sessions (low first)</option>
              <option value="domain-az">Domain A&rarr;Z</option>
              <option value="pageviews-desc">Pageviews (high first)</option>
              <option value="users-desc">Users (high first)</option>
            </select>
            <label style="margin-left:12px">Rows:</label>
            <input type="range" id="prop-density" min="10" max="150" step="10" value="50"
                   oninput="document.getElementById('prop-density-val').textContent=this.value; renderPropertyTable()">
            <span id="prop-density-val" style="font-size:12px;color:var(--muted);min-width:28px">50</span>
          </div>
          <div style="max-height:600px;overflow-y:auto">
            <table>
              <thead>
                <tr>
                  <th>#</th>
                  <th>Domain</th>
                  <th>Measurement ID</th>
                  <th>Sessions</th>
                  <th>Users</th>
                  <th>Pageviews</th>
                  <th>Relative</th>
                  <th>Status</th>
                </tr>
              </thead>
              <tbody id="prop-tbody"></tbody>
            </table>
          </div>
        </div>
      </div>

      <!-- Per-property bar chart: top 20 by sessions -->
      <div class="chart-card">
        <div class="section-header">
          <h2>Sessions by Property <span class="badge">Top 20</span></h2>
        </div>
        <div class="chart-container tall">
          <canvas id="chart-prop-sessions"></canvas>
          <div class="placeholder-chart" id="ph-prop-sessions" style="display:none">
            <div class="icon">&#x1f4ca;</div>
            <div>No data</div>
          </div>
        </div>
      </div>

    </div><!-- /tab-properties -->

    <!-- ── FLEET AUDIT TAB ── -->
    <div id="tab-audit" class="tab-content">

      <div class="section-header">
        <h2>GA4 Fleet Audit <span class="badge" id="audit-badge"></span></h2>
      </div>

      <div class="two-col" style="margin-bottom:20px">
        <div class="chart-card">
          <div class="section-header"><h2>Coverage</h2></div>
          <div class="chart-container" style="height:220px">
            <canvas id="chart-coverage"></canvas>
          </div>
        </div>
        <div class="chart-card">
          <div class="section-header"><h2>Data Availability</h2></div>
          <div class="chart-container" style="height:220px">
            <canvas id="chart-data-avail"></canvas>
          </div>
        </div>
      </div>

      <div class="prop-table-wrap">
        <div style="max-height:500px;overflow-y:auto">
          <table>
            <thead>
              <tr>
                <th>#</th>
                <th>Domain</th>
                <th>G-ID</th>
                <th>Numeric ID</th>
                <th>Status</th>
                <th>Note</th>
              </tr>
            </thead>
            <tbody id="audit-tbody"></tbody>
          </table>
        </div>
      </div>

    </div><!-- /tab-audit -->

    <!-- Refresh log -->
    <div id="refresh-log"></div>

  </div><!-- /dashboard -->
</main>

<footer>
  ga-global-viewer &mdash; Steve Abrams APPS &mdash;
  <span id="footer-generated"></span> &mdash;
  <a href="/api/stats" style="color:var(--accent);text-decoration:none">JSON API</a> &mdash;
  <a href="/api/properties" style="color:var(--accent);text-decoration:none">Properties</a>
</footer>

<script>
// ── state ────────────────────────────────────────────────────────────────────
let STATE = null;
let PROPERTIES_MAP = {};
let charts = {};

// Persist sort + density
const PREFS = {
  get sort() { return localStorage.getItem('ga_prop_sort') || 'sessions-desc'; },
  set sort(v) { localStorage.setItem('ga_prop_sort', v); },
  get density() { return parseInt(localStorage.getItem('ga_prop_density') || '50'); },
  set density(v) { localStorage.setItem('ga_prop_density', String(v)); },
};

// ── init ─────────────────────────────────────────────────────────────────────
async function init() {
  // Restore prefs
  document.getElementById('prop-sort').value = PREFS.sort;
  document.getElementById('prop-density').value = PREFS.density;
  document.getElementById('prop-density-val').textContent = PREFS.density;

  try {
    // Load properties map for audit tab
    const pResp = await fetch('/api/properties');
    const pData = await pResp.json();
    PROPERTIES_MAP = pData.properties || {};
  } catch (e) { /* ignore */ }

  await loadStats();
}

async function loadStats() {
  document.getElementById('loading').style.display = 'flex';
  document.getElementById('dashboard').style.display = 'none';
  document.getElementById('auth-banner').style.display = 'none';

  try {
    const resp = await fetch('/api/stats');
    STATE = await resp.json();
    renderDashboard(STATE);
  } catch (e) {
    document.getElementById('loading').innerHTML =
      `<div style="color:var(--danger)">Failed to load stats: ${e.message}</div>`;
  }
}

// ── render ────────────────────────────────────────────────────────────────────
function renderDashboard(data) {
  document.getElementById('loading').style.display = 'none';
  document.getElementById('dashboard').style.display = 'block';

  // Auth banner
  if (!data.auth_ok) {
    document.getElementById('auth-banner').style.display = 'block';
    document.getElementById('auth-error-msg').textContent = data.auth_error || 'Authentication failed.';
  }

  // Cache age
  if (data._cache_age_s != null) {
    const age = formatAge(data._cache_age_s);
    document.getElementById('cache-age').textContent = `Cache: ${age} old`;
  }

  // Footer
  if (data.generated_at) {
    document.getElementById('footer-generated').textContent =
      'Generated ' + new Date(data.generated_at).toLocaleString(undefined, {
        year:'numeric', month:'short', day:'numeric',
        hour:'numeric', minute:'2-digit'
      });
  }

  const g = data.global || {};
  const totals = g.totals || { sessions:0, users:0, pageviews:0 };
  const propsWithData = data.properties_with_data || 0;

  // Stat cards
  document.getElementById('card-sessions').textContent = fmt(totals.sessions);
  document.getElementById('card-users').textContent = fmt(totals.users);
  document.getElementById('card-pageviews').textContent = fmt(totals.pageviews);
  document.getElementById('card-props').textContent = fmt(propsWithData);
  document.getElementById('card-props-sub').textContent =
    `of ${data.properties_count || 0} properties tracked`;
  document.getElementById('card-avg').textContent =
    propsWithData > 0 ? fmt(Math.round(totals.sessions / propsWithData)) : '—';
  document.getElementById('card-depth').textContent =
    totals.sessions > 0 ? (totals.pageviews / totals.sessions).toFixed(1) : '—';

  // Date range label
  document.getElementById('date-range-label').textContent =
    `${data.date_range?.start || '30daysAgo'} — ${data.date_range?.end || 'yesterday'}`;

  // Charts — global
  renderTimeseriesChart(g.timeseries || []);
  renderBarChart('chart-pages', 'ph-pages', (g.top_pages || []).slice(0,12),
    'path', 'pageviews', 'Page Views', '#6c8cff');
  renderBarChart('chart-sources', 'ph-sources', (g.top_sources || []).slice(0,12),
    'source', 'sessions', 'Sessions', '#4fffb0');

  // Per-property badge
  document.getElementById('prop-count-badge').textContent =
    `${propsWithData} with data / ${data.properties_count} total`;

  renderPropertyTable();
  renderPropertyBarChart();
  renderAuditTab();
}

// ── timeseries chart ──────────────────────────────────────────────────────────
function renderTimeseriesChart(timeseries) {
  if (!timeseries.length) {
    document.getElementById('ph-timeseries').style.display = 'flex';
    return;
  }
  const labels = timeseries.map(r => {
    const d = r.date; // YYYYMMDD
    return `${d.slice(4,6)}/${d.slice(6,8)}`;
  });
  const sessions = timeseries.map(r => r.sessions);
  const users = timeseries.map(r => r.users);
  const pageviews = timeseries.map(r => r.pageviews);

  destroyChart('chart-timeseries');
  const ctx = document.getElementById('chart-timeseries').getContext('2d');
  charts['chart-timeseries'] = new Chart(ctx, {
    type: 'line',
    data: {
      labels,
      datasets: [
        {
          label: 'Sessions',
          data: sessions,
          borderColor: '#6c8cff',
          backgroundColor: 'rgba(108,140,255,0.12)',
          fill: true,
          tension: 0.3,
          pointRadius: 2,
          borderWidth: 2,
        },
        {
          label: 'Users',
          data: users,
          borderColor: '#4fffb0',
          backgroundColor: 'rgba(79,255,176,0.08)',
          fill: false,
          tension: 0.3,
          pointRadius: 2,
          borderWidth: 2,
        },
        {
          label: 'Pageviews',
          data: pageviews,
          borderColor: '#ff7c5c',
          backgroundColor: 'rgba(255,124,92,0.08)',
          fill: false,
          tension: 0.3,
          pointRadius: 2,
          borderWidth: 1.5,
        },
      ],
    },
    options: darkChartOptions({
      plugins: {
        legend: { position: 'top', labels: { color: '#7a829a', usePointStyle: true, pointStyleWidth: 10, boxHeight: 6 } },
        tooltip: { mode: 'index', intersect: false },
      },
      scales: {
        x: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', maxTicksLimit: 15 } },
        y: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', callback: v => fmtK(v) } },
      },
    }),
  });
}

// ── bar chart ─────────────────────────────────────────────────────────────────
function renderBarChart(canvasId, phId, items, labelKey, valueKey, metricLabel, color) {
  if (!items.length) {
    document.getElementById(phId).style.display = 'flex';
    return;
  }
  document.getElementById(phId).style.display = 'none';
  const labels = items.map(r => truncate(r[labelKey], 35));
  const values = items.map(r => r[valueKey]);

  destroyChart(canvasId);
  const ctx = document.getElementById(canvasId).getContext('2d');
  charts[canvasId] = new Chart(ctx, {
    type: 'bar',
    data: {
      labels,
      datasets: [{
        label: metricLabel,
        data: values,
        backgroundColor: color + 'cc',
        borderColor: color,
        borderWidth: 1,
        borderRadius: 4,
      }],
    },
    options: darkChartOptions({
      indexAxis: 'y',
      plugins: {
        legend: { display: false },
        tooltip: { callbacks: { label: ctx => ` ${fmt(ctx.raw)}` } },
      },
      scales: {
        x: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', callback: v => fmtK(v) } },
        y: { grid: { color: 'transparent' }, ticks: { color: '#e8ecf4', font: { size: 11 } } },
      },
    }),
  });
}

// ── property table ─────────────────────────────────────────────────────────────
function renderPropertyTable() {
  const sort = document.getElementById('prop-sort').value;
  const limit = parseInt(document.getElementById('prop-density').value);
  PREFS.sort = sort;
  PREFS.density = limit;

  if (!STATE) return;
  const perProp = (STATE.per_property || []).filter(p => p.totals);

  const sorted = [...perProp].sort((a, b) => {
    switch (sort) {
      case 'sessions-desc': return (b.totals?.sessions||0) - (a.totals?.sessions||0);
      case 'sessions-asc':  return (a.totals?.sessions||0) - (b.totals?.sessions||0);
      case 'domain-az':     return a.domain.localeCompare(b.domain);
      case 'pageviews-desc': return (b.totals?.pageviews||0) - (a.totals?.pageviews||0);
      case 'users-desc':    return (b.totals?.users||0) - (a.totals?.users||0);
      default: return 0;
    }
  });

  const maxSessions = Math.max(1, ...sorted.map(p => p.totals?.sessions||0));
  const rows = sorted.slice(0, limit);
  const tbody = document.getElementById('prop-tbody');

  tbody.innerHTML = rows.map((p, i) => {
    const s = p.totals?.sessions || 0;
    const u = p.totals?.users || 0;
    const pv = p.totals?.pageviews || 0;
    const pct = Math.round((s / maxSessions) * 100);
    return `<tr>
      <td class="num" style="color:var(--muted)">${i+1}</td>
      <td class="domain-cell">${escHtml(p.domain)}</td>
      <td class="mid-cell">${escHtml(p.measurement_id)}</td>
      <td class="num">${fmt(s)}</td>
      <td class="num">${fmt(u)}</td>
      <td class="num">${fmt(pv)}</td>
      <td class="bar-cell">
        <div class="mini-bar-wrap"><div class="mini-bar" style="width:${pct}%"></div></div>
      </td>
      <td><span class="status-ok">live</span></td>
    </tr>`;
  }).join('');

  // Rows with errors
  const errorRows = (STATE.per_property || []).filter(p => p.error);
  if (errorRows.length && rows.length < limit) {
    tbody.innerHTML += errorRows.slice(0, limit - rows.length).map((p, i) => `<tr>
      <td class="num" style="color:var(--muted)">${rows.length+i+1}</td>
      <td class="domain-cell" style="color:var(--muted)">${escHtml(p.domain)}</td>
      <td class="mid-cell">${escHtml(p.measurement_id)}</td>
      <td class="num" style="color:var(--muted)">—</td>
      <td class="num" style="color:var(--muted)">—</td>
      <td class="num" style="color:var(--muted)">—</td>
      <td class="bar-cell"></td>
      <td><span class="status-${p.error === 'permission_denied' ? 'err' : 'no-data'}">${escHtml(p.error)}</span></td>
    </tr>`).join('');
  }
}

// ── property bar chart ────────────────────────────────────────────────────────
function renderPropertyBarChart() {
  if (!STATE) return;
  const perProp = (STATE.per_property || []).filter(p => p.totals);
  const top20 = [...perProp]
    .sort((a,b) => (b.totals?.sessions||0) - (a.totals?.sessions||0))
    .slice(0, 20);

  if (!top20.length) {
    document.getElementById('ph-prop-sessions').style.display = 'flex';
    return;
  }

  const labels = top20.map(p => truncate(p.domain, 25));
  const values = top20.map(p => p.totals?.sessions || 0);

  destroyChart('chart-prop-sessions');
  const ctx = document.getElementById('chart-prop-sessions').getContext('2d');
  charts['chart-prop-sessions'] = new Chart(ctx, {
    type: 'bar',
    data: {
      labels,
      datasets: [{
        label: 'Sessions',
        data: values,
        backgroundColor: values.map((_, i) =>
          `hsl(${220 + i * 8}, 70%, 55%)`
        ),
        borderRadius: 4,
        borderWidth: 0,
      }],
    },
    options: darkChartOptions({
      indexAxis: 'y',
      plugins: {
        legend: { display: false },
        tooltip: { callbacks: { label: ctx => ` ${fmt(ctx.raw)} sessions` } },
      },
      scales: {
        x: { grid: { color: '#2a2f45' }, ticks: { color: '#7a829a', callback: v => fmtK(v) } },
        y: { grid: { color: 'transparent' }, ticks: { color: '#e8ecf4', font: { size: 11 } } },
      },
    }),
  });
}

// ── audit tab ─────────────────────────────────────────────────────────────────
function renderAuditTab() {
  const allDomains = Object.keys(PROPERTIES_MAP);
  const queriedMap = {};
  if (STATE) {
    for (const p of (STATE.per_property || [])) {
      queriedMap[p.domain] = p;
    }
  }

  document.getElementById('audit-badge').textContent =
    `${allDomains.length} properties in fleet`;

  const withData = (STATE?.per_property||[]).filter(p=>p.totals).length;
  const withError = (STATE?.per_property||[]).filter(p=>p.error).length;
  const notQueried = allDomains.filter(d=>!queriedMap[d]).length;

  // Coverage doughnut
  destroyChart('chart-coverage');
  const cCtx = document.getElementById('chart-coverage').getContext('2d');
  charts['chart-coverage'] = new Chart(cCtx, {
    type: 'doughnut',
    data: {
      labels: ['Has Data', 'API Error', 'Not Queried'],
      datasets: [{
        data: [withData, withError, notQueried],
        backgroundColor: ['#3fd68c', '#ff4d4f', '#2a2f45'],
        borderColor: '#1a1d27',
        borderWidth: 3,
      }],
    },
    options: darkChartOptions({
      cutout: '65%',
      plugins: {
        legend: { position: 'bottom', labels: { color: '#7a829a', padding: 16 } },
      },
    }),
  });

  // Data availability doughnut
  const pidMap = {};
  // Build from per_property
  if (STATE) {
    for (const p of (STATE.per_property||[])) {
      pidMap[p.measurement_id] = p.numeric_id ? 'mapped' : 'no_pid';
    }
  }
  const mapped = (STATE?.per_property||[]).filter(p=>p.numeric_id).length;
  const noId = (STATE?.per_property||[]).filter(p=>!p.numeric_id && !p.error).length;
  destroyChart('chart-data-avail');
  const dCtx = document.getElementById('chart-data-avail').getContext('2d');
  charts['chart-data-avail'] = new Chart(dCtx, {
    type: 'doughnut',
    data: {
      labels: ['Has Data', 'Permission Denied', 'No Numeric ID', 'Not Queried'],
      datasets: [{
        data: [
          withData,
          (STATE?.per_property||[]).filter(p=>p.error==='permission_denied').length,
          (STATE?.per_property||[]).filter(p=>p.error==='no_numeric_id').length,
          notQueried,
        ],
        backgroundColor: ['#6c8cff', '#ff7c5c', '#f5a623', '#2a2f45'],
        borderColor: '#1a1d27',
        borderWidth: 3,
      }],
    },
    options: darkChartOptions({
      cutout: '65%',
      plugins: {
        legend: { position: 'bottom', labels: { color: '#7a829a', padding: 16, boxWidth: 12 } },
      },
    }),
  });

  // Audit table
  const tbody = document.getElementById('audit-tbody');
  const rows = allDomains.map((domain, i) => {
    const mid = PROPERTIES_MAP[domain];
    const entry = queriedMap[domain];
    let status, note;
    if (!entry) {
      status = '<span class="status-no-data">not queried</span>';
      note = 'Run refresh to pull data';
    } else if (entry.totals) {
      status = '<span class="status-ok">data available</span>';
      note = `${fmt(entry.totals.sessions)} sessions`;
    } else if (entry.error === 'permission_denied') {
      status = '<span class="status-err">permission denied</span>';
      note = 'Service account lacks viewer role on this property';
    } else if (entry.error === 'no_numeric_id') {
      status = '<span class="status-no-data">no property ID</span>';
      note = 'Not in property-ids.json — run list_properties.py';
    } else {
      status = `<span class="status-err">${escHtml(entry.error||'error')}</span>`;
      note = escHtml((entry.detail||'').slice(0,80));
    }
    return `<tr>
      <td class="num" style="color:var(--muted)">${i+1}</td>
      <td class="domain-cell">${escHtml(domain)}</td>
      <td class="mid-cell">${escHtml(mid)}</td>
      <td class="mid-cell">${escHtml(entry?.numeric_id||'—')}</td>
      <td>${status}</td>
      <td style="color:var(--muted);font-size:12px">${note}</td>
    </tr>`;
  });
  tbody.innerHTML = rows.join('');
}

// ── refresh ───────────────────────────────────────────────────────────────────
async function triggerRefresh() {
  const btn = document.getElementById('btn-refresh');
  btn.disabled = true;
  btn.innerHTML = '<span class="spinner"></span>Refreshing...';

  const logEl = document.getElementById('refresh-log');
  logEl.style.display = 'block';
  logEl.textContent = 'Starting refresh...';

  await fetch('/api/refresh');

  // Poll status
  const poll = setInterval(async () => {
    try {
      const s = await (await fetch('/api/refresh-status')).json();
      logEl.textContent = s.log.join('\n');
      logEl.scrollTop = logEl.scrollHeight;
      if (!s.running) {
        clearInterval(poll);
        btn.disabled = false;
        btn.innerHTML = 'Refresh Data';
        // Reload data
        await loadStats();
      }
    } catch (e) {
      clearInterval(poll);
      btn.disabled = false;
      btn.innerHTML = 'Refresh Data';
    }
  }, 2000);
}

// ── tabs ──────────────────────────────────────────────────────────────────────
function switchTab(name) {
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
  document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
  document.getElementById(`tab-${name}`).classList.add('active');
  // Activate correct tab button
  document.querySelectorAll('.tab').forEach(t => {
    if (t.getAttribute('onclick') === `switchTab('${name}')`) t.classList.add('active');
  });
}

// ── helpers ───────────────────────────────────────────────────────────────────
function fmt(n) {
  if (n == null || isNaN(n)) return '0';
  if (n >= 1_000_000) return (n/1_000_000).toFixed(1) + 'M';
  if (n >= 10_000) return Math.round(n/1000) + 'K';
  return n.toLocaleString();
}

function fmtK(n) {
  if (n >= 1_000_000) return (n/1_000_000).toFixed(1)+'M';
  if (n >= 1000) return Math.round(n/1000)+'K';
  return n;
}

function truncate(s, max) {
  if (!s) return '';
  return s.length > max ? s.slice(0, max-1) + '…' : s;
}

function escHtml(s) {
  return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

function formatAge(seconds) {
  if (seconds < 60) return `${seconds}s`;
  if (seconds < 3600) return `${Math.round(seconds/60)}m`;
  if (seconds < 86400) return `${Math.round(seconds/3600)}h`;
  return `${Math.round(seconds/86400)}d`;
}

function destroyChart(id) {
  if (charts[id]) { charts[id].destroy(); delete charts[id]; }
}

function darkChartOptions(overrides = {}) {
  return {
    responsive: true,
    maintainAspectRatio: false,
    animation: { duration: 500 },
    ...overrides,
    plugins: {
      ...(overrides.plugins || {}),
    },
  };
}

// ── boot ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>