← back to Ga4 Dashboard

server.js

734 lines

/**
 * GA4 Fleet Traffic Dashboard — local server, port 9710
 * Serves cached data from cache/data.json. Never makes live API calls on page load.
 * Basic-auth: credentials from env (GA4_DASH_USER / GA4_DASH_PASS); dev fallback only.
 *
 * Cost: $0 local (cache reads, no external API calls per page hit)
 */

const express = require('express');
const basicAuth = require('basic-auth');
const fs = require('fs');
const path = require('path');

const PORT = 9710;
const CACHE_FILE = path.join(__dirname, 'cache', 'data.json');
// Credentials from the environment; the literals are a dev-only fallback so a fresh
// checkout still runs. Set GA4_DASH_USER / GA4_DASH_PASS in the pm2/launchd env for real use.
const DASH_USER = process.env.GA4_DASH_USER || 'admin';
const DASH_PASS = process.env.GA4_DASH_PASS || 'DW2024!';

const app = express();

// Basic auth middleware
app.use((req, res, next) => {
  const creds = basicAuth(req);
  if (!creds || creds.name !== DASH_USER || creds.pass !== DASH_PASS) {
    res.set('WWW-Authenticate', 'Basic realm="GA4 Dashboard"');
    return res.status(401).send('Unauthorized');
  }
  next();
});

// API: return raw cache as JSON
app.get('/api/data', (req, res) => {
  if (!fs.existsSync(CACHE_FILE)) {
    return res.status(503).json({ error: 'Cache not built yet. Run: python3 etl.py' });
  }
  try {
    const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
    res.json(raw);
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// API: cache freshness check
app.get('/api/status', (req, res) => {
  if (!fs.existsSync(CACHE_FILE)) {
    return res.json({ cache: 'missing', message: 'Run: python3 etl.py' });
  }
  // Guarded: a corrupt/half-written cache must NOT crash the process here.
  try {
    const stat = fs.statSync(CACHE_FILE);
    const raw = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
    res.json({
      cache: 'ready',
      fetched_at: raw.fetched_at,
      property_count: raw.property_count,
      error_count: raw.error_count,
      file_mtime: stat.mtime,
      file_size_kb: Math.round(stat.size / 1024),
    });
  } catch (e) {
    res.status(500).json({ cache: 'corrupt', error: e.message });
  }
});

// Main dashboard HTML
app.get('/', (req, res) => {
  res.send(getDashboardHTML());
});

app.listen(PORT, '127.0.0.1', () => {
  console.log(`GA4 Fleet Dashboard running at http://127.0.0.1:${PORT}`);
  console.log(`Cost: $0 local — no live API calls on page load`);
  if (!fs.existsSync(CACHE_FILE)) {
    console.log('WARNING: cache not found. Run: python3 etl.py first');
  }
});

function getDashboardHTML() {
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GA4 Fleet Traffic Dashboard</title>
<style>
  :root {
    --bg: #0f1117;
    --surface: #1a1d27;
    --border: #2a2d3a;
    --accent: #4f8ef7;
    --text: #e2e8f0;
    --text-dim: #8892a4;
    --green: #34d399;
    --yellow: #fbbf24;
    --red: #f87171;
    --cols: 3;
  }
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body {
    background: var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
    font-size: 14px;
    min-height: 100vh;
  }
  header {
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    padding: 14px 20px;
    display: flex;
    align-items: center;
    gap: 16px;
    flex-wrap: wrap;
    position: sticky;
    top: 0;
    z-index: 100;
  }
  header h1 { font-size: 16px; font-weight: 600; color: var(--accent); flex: 1; min-width: 200px; }
  .meta { font-size: 11px; color: var(--text-dim); }
  .meta span { color: var(--green); }
  .controls {
    display: flex;
    gap: 10px;
    align-items: center;
    flex-wrap: wrap;
  }
  .controls label { font-size: 12px; color: var(--text-dim); }
  select, input[type=text] {
    background: var(--bg);
    border: 1px solid var(--border);
    color: var(--text);
    padding: 5px 8px;
    border-radius: 4px;
    font-size: 12px;
  }
  input[type=range] {
    width: 80px;
    accent-color: var(--accent);
  }
  .badge {
    background: var(--border);
    border-radius: 3px;
    padding: 2px 6px;
    font-size: 11px;
    color: var(--text-dim);
  }
  .badge.ok { background: #1a3a2a; color: var(--green); }
  .badge.warn { background: #3a2a1a; color: var(--yellow); }
  .badge.err { background: #3a1a1a; color: var(--red); }

  #search {
    width: 200px;
  }

  main {
    padding: 16px 20px;
  }
  .summary-row {
    display: flex;
    gap: 12px;
    margin-bottom: 16px;
    flex-wrap: wrap;
  }
  .stat-card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 12px 16px;
    min-width: 140px;
  }
  .stat-card .label { font-size: 11px; color: var(--text-dim); margin-bottom: 4px; }
  .stat-card .val { font-size: 22px; font-weight: 700; color: var(--accent); }

  #grid {
    display: grid;
    grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
    gap: 12px;
  }

  .card {
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 14px;
    transition: border-color 0.15s;
    cursor: pointer;
  }
  .card:hover { border-color: var(--accent); }
  .card-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 8px;
    gap: 8px;
  }
  .card-name {
    font-weight: 600;
    font-size: 13px;
    color: var(--text);
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .card-name a {
    color: inherit;
    text-decoration: none;
  }
  .card-name a:hover { color: var(--accent); text-decoration: underline; }
  .card-domain {
    font-size: 11px;
    color: var(--text-dim);
    margin-bottom: 8px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .card-domain a {
    color: var(--text-dim);
    text-decoration: none;
  }
  .card-domain a:hover { color: var(--accent); }

  .metrics {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 6px;
    margin-bottom: 8px;
  }
  .metric {
    background: var(--bg);
    border-radius: 4px;
    padding: 6px 8px;
  }
  .metric .m-label { font-size: 10px; color: var(--text-dim); }
  .metric .m-val { font-size: 15px; font-weight: 700; color: var(--text); }
  .metric .m-val a { color: inherit; text-decoration: none; }
  .metric .m-val a:hover { color: var(--accent); text-decoration: underline; }
  .metric .m-sub { font-size: 10px; color: var(--text-dim); }

  .card-footer {
    font-size: 10px;
    color: var(--text-dim);
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  .card-footer a { color: var(--text-dim); text-decoration: none; }
  .card-footer a:hover { color: var(--accent); text-decoration: underline; }

  .when {
    font-size: 10px;
    color: var(--text-dim);
    margin-top: 6px;
  }

  #empty { color: var(--text-dim); text-align: center; padding: 40px; }
  .loading { color: var(--text-dim); text-align: center; padding: 60px; font-size: 16px; }

  .density-label { font-size: 12px; color: var(--text-dim); white-space: nowrap; }

  /* Mode toggle: grid vs table */
  .mode-btn {
    background: var(--border);
    border: none;
    color: var(--text-dim);
    padding: 5px 10px;
    border-radius: 4px;
    cursor: pointer;
    font-size: 12px;
  }
  .mode-btn.active { background: var(--accent); color: #fff; }

  /* Table mode */
  #table-wrap { display: none; overflow-x: auto; }
  table {
    width: 100%;
    border-collapse: collapse;
    font-size: 12px;
  }
  th {
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    padding: 8px 10px;
    text-align: left;
    color: var(--text-dim);
    position: sticky;
    top: 58px;
    cursor: pointer;
    white-space: nowrap;
    user-select: none;
  }
  th:hover { color: var(--accent); }
  th .sort-arrow { opacity: 0.4; }
  th.sorted .sort-arrow { opacity: 1; color: var(--accent); }
  td {
    border-bottom: 1px solid var(--border);
    padding: 7px 10px;
    color: var(--text);
  }
  tr:hover td { background: var(--surface); }
  td a { color: var(--accent); text-decoration: none; }
  td a:hover { text-decoration: underline; }
  td.num { text-align: right; font-variant-numeric: tabular-nums; }
  .no-data { color: var(--text-dim); }
  /* Duplicate-domain visual treatment (Fix A) */
  .dup-badge {
    font-size: 10px;
    color: var(--text-dim);
    background: var(--border);
    border-radius: 3px;
    padding: 1px 5px;
    opacity: 0.7;
    margin-left: 4px;
    cursor: default;
  }
  .card.is-dup { opacity: 0.6; }
  tr.is-dup td { opacity: 0.6; }
</style>
</head>
<body>
<header>
  <h1>GA4 Fleet Traffic</h1>
  <div class="meta" id="meta">Loading...</div>
  <div class="controls">
    <input type="text" id="search" placeholder="Filter sites..." oninput="render()">

    <label>Sort:
      <select id="sort" onchange="render()">
        <option value="sessions_7d_desc">Sessions 7d (high)</option>
        <option value="sessions_30d_desc">Sessions 30d (high)</option>
        <option value="users_7d_desc">Users 7d (high)</option>
        <option value="pageviews_7d_desc">Pageviews 7d (high)</option>
        <option value="alpha">Name A→Z</option>
        <option value="domain">Domain A→Z</option>
        <option value="created_desc">Created (newest)</option>
        <option value="created_asc">Created (oldest)</option>
      </select>
    </label>

    <label>Window:
      <select id="window" onchange="render()">
        <option value="7d">7 days</option>
        <option value="30d">30 days</option>
        <option value="both">Both</option>
      </select>
    </label>

    <span class="density-label">Density:</span>
    <input type="range" id="density" min="1" max="6" value="3"
           oninput="updateDensity(this.value)">
    <span id="density-val" style="font-size:11px;color:var(--text-dim)">3 col</span>

    <label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-dim);cursor:pointer">
      <input type="checkbox" id="hide-dups" onchange="render()" style="accent-color:var(--accent)">
      Hide duplicates
    </label>

    <button class="mode-btn active" id="btn-grid" onclick="setMode('grid')">Grid</button>
    <button class="mode-btn" id="btn-table" onclick="setMode('table')">Table</button>
  </div>
</header>

<main>
  <div class="summary-row" id="summary"></div>
  <div id="grid"></div>
  <div id="table-wrap">
    <table id="tbl">
      <thead id="thead"></thead>
      <tbody id="tbody"></tbody>
    </table>
  </div>
  <div id="empty" style="display:none">No matching properties.</div>
  <p class="loading" id="loading">Loading cache...</p>
</main>

<script>
let ALL = [];
let sortState = { col: null, dir: 1 };
let viewMode = 'grid';

// Persist preferences in localStorage
const PREFS_KEY = 'ga4dash_prefs';
function loadPrefs() {
  try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); } catch { return {}; }
}
function savePrefs() {
  const p = {
    sort: qs('sort').value,
    window: qs('window').value,
    density: qs('density').value,
    mode: viewMode,
    hideDups: qs('hide-dups').checked,
  };
  localStorage.setItem(PREFS_KEY, JSON.stringify(p));
}
function applyPrefs() {
  const p = loadPrefs();
  if (p.sort) qs('sort').value = p.sort;
  if (p.window) qs('window').value = p.window;
  if (p.density) { qs('density').value = p.density; updateDensity(p.density, false); }
  if (p.mode) setMode(p.mode, false);
  // Hide-duplicates checkbox: default CHECKED (true) when no stored pref yet
  qs('hide-dups').checked = (p.hideDups !== undefined) ? p.hideDups : true;
}

function qs(id) { return document.getElementById(id); }

function updateDensity(val, save = true) {
  document.documentElement.style.setProperty('--cols', val);
  qs('density-val').textContent = val + ' col';
  if (save) savePrefs();
}

function setMode(mode, save = true) {
  viewMode = mode;
  qs('btn-grid').classList.toggle('active', mode === 'grid');
  qs('btn-table').classList.toggle('active', mode === 'table');
  qs('grid').style.display = mode === 'grid' ? '' : 'none';
  qs('table-wrap').style.display = mode === 'table' ? '' : 'none';
  if (save) { savePrefs(); render(); }
}

function fmtNum(n) {
  if (!n && n !== 0) return '-';
  return n.toLocaleString();
}

function fmtDate(iso) {
  if (!iso) return '-';
  return new Date(iso).toLocaleString(undefined, {
    year: 'numeric', month: 'short', day: 'numeric',
    hour: 'numeric', minute: '2-digit'
  });
}

function ga4ReportUrl(propertyId) {
  return \`https://analytics.google.com/analytics/web/#/p\${propertyId}/reports/explorer\`;
}

function sortData(data) {
  const sortKey = qs('sort').value;
  return [...data].sort((a, b) => {
    const s7a = a.stats_7d?.sessions || 0, s7b = b.stats_7d?.sessions || 0;
    const s30a = a.stats_30d?.sessions || 0, s30b = b.stats_30d?.sessions || 0;
    const u7a = a.stats_7d?.activeUsers || 0, u7b = b.stats_7d?.activeUsers || 0;
    const pv7a = a.stats_7d?.screenPageViews || 0, pv7b = b.stats_7d?.screenPageViews || 0;
    switch (sortKey) {
      case 'sessions_7d_desc': return s7b - s7a;
      case 'sessions_30d_desc': return s30b - s30a;
      case 'users_7d_desc': return u7b - u7a;
      case 'pageviews_7d_desc': return pv7b - pv7a;
      case 'alpha': return (a.display_name || '').localeCompare(b.display_name || '');
      case 'domain': return (a.domain || '').localeCompare(b.domain || '');
      case 'created_desc': return new Date(b.create_time || 0) - new Date(a.create_time || 0);
      case 'created_asc': return new Date(a.create_time || 0) - new Date(b.create_time || 0);
      default: return s7b - s7a;
    }
  });
}

function filterData(data) {
  // Duplicate filter (Fix A): when checked, exclude non-canonical entries
  const hideDups = qs('hide-dups').checked;
  let filtered = hideDups ? data.filter(p => !p.duplicate_domain) : data;

  const q = qs('search').value.trim().toLowerCase();
  if (!q) return filtered;
  const terms = q.split(' ').filter(Boolean);
  return filtered.filter(p => {
    const haystack = [p.display_name, p.domain, p.property_id, p.measurement_id, p.account_name]
      .join(' ').toLowerCase();
    return terms.every(t => haystack.includes(t));
  });
}

function renderSummary(data) {
  const total_7d = data.reduce((s, p) => s + (p.stats_7d?.sessions || 0), 0);
  const total_30d = data.reduce((s, p) => s + (p.stats_30d?.sessions || 0), 0);
  const total_users_7d = data.reduce((s, p) => s + (p.stats_7d?.activeUsers || 0), 0);
  // Active count: exclude non-canonical duplicates (Fix A — dedup)
  const active_7d = data.filter(p => !p.duplicate_domain && (p.stats_7d?.sessions || 0) > 0).length;
  qs('summary').innerHTML = \`
    <div class="stat-card"><div class="label">Sites</div><div class="val">\${data.length}</div></div>
    <div class="stat-card"><div class="label">Active (7d)</div><div class="val">\${active_7d}</div></div>
    <div class="stat-card"><div class="label">Sessions (7d)</div><div class="val">\${fmtNum(total_7d)}</div></div>
    <div class="stat-card"><div class="label">Users (7d)</div><div class="val">\${fmtNum(total_users_7d)}</div></div>
    <div class="stat-card"><div class="label">Sessions (30d)</div><div class="val">\${fmtNum(total_30d)}</div></div>
  \`;
}

function render() {
  savePrefs();
  const win = qs('window').value;
  // In table mode with an active column sort, use the column sort order;
  // otherwise use the dropdown sort (used for grid and initial table state).
  const sorted = (viewMode === 'table' && sortState.col)
    ? sortByState(ALL)
    : sortData(ALL);
  const filtered = filterData(sorted);
  renderSummary(filtered);

  const empty = qs('empty');
  empty.style.display = filtered.length === 0 ? '' : 'none';

  if (viewMode === 'grid') {
    renderGrid(filtered, win);
  } else {
    renderTable(filtered, win);
  }
}

function renderGrid(data, win) {
  const grid = qs('grid');
  grid.innerHTML = data.map(p => {
    const pid = p.property_id;
    const s7 = p.stats_7d || {};
    const s30 = p.stats_30d || {};
    const reportUrl = ga4ReportUrl(pid);
    const domainDisplay = p.domain ? p.domain.replace(/^https?:\\/\\//, '') : '-';
    const domainLink = p.domain ? \`<a href="\${p.domain}" target="_blank" rel="noopener">\${domainDisplay}</a>\` : domainDisplay;
    const errBadge = p.error ? \`<span class="badge err" title="\${p.error}">ERR</span>\` : '';
    // Duplicate-domain badge (Fix A)
    const dupBadge = p.duplicate_domain ? \`<span class="dup-badge" title="Non-canonical duplicate of this domain — another property has more sessions">⧉ dup</span>\` : '';
    const hasSessions = (s7.sessions || 0) > 0 || (s30.sessions || 0) > 0;

    let metricsHtml = '';
    if (win === '7d' || win === 'both') {
      metricsHtml += \`
        <div class="metric">
          <div class="m-label">Sessions (7d)</div>
          <div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s7.sessions || 0)}</a></div>
        </div>
        <div class="metric">
          <div class="m-label">Users (7d)</div>
          <div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s7.activeUsers || 0)}</a></div>
        </div>
        <div class="metric">
          <div class="m-label">Pageviews (7d)</div>
          <div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s7.screenPageViews || 0)}</a></div>
          <div class="m-sub"></div>
        </div>\`;
    }
    if (win === '30d' || win === 'both') {
      metricsHtml += \`
        <div class="metric">
          <div class="m-label">Sessions (30d)</div>
          <div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s30.sessions || 0)}</a></div>
        </div>
        <div class="metric">
          <div class="m-label">Users (30d)</div>
          <div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s30.activeUsers || 0)}</a></div>
        </div>
        <div class="metric">
          <div class="m-label">Pageviews (30d)</div>
          <div class="m-val"><a href="\${reportUrl}" target="_blank" rel="noopener">\${fmtNum(s30.screenPageViews || 0)}</a></div>
        </div>\`;
    }

    const createTs = p.create_time ? new Date(p.create_time).toLocaleString(undefined, {
      year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
    }) : null;

    return \`
      <div class="card\${p.duplicate_domain ? ' is-dup' : ''}" onclick="window.open('\${reportUrl}','_blank')">
        <div class="card-header">
          <div class="card-name"><a href="\${reportUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation()">\${p.display_name || pid}</a>\${dupBadge}</div>
          \${errBadge}
          \${!hasSessions ? '<span class="badge">0 traffic</span>' : ''}
        </div>
        <div class="card-domain">\${domainLink}</div>
        <div class="metrics">\${metricsHtml}</div>
        <div class="card-footer">
          <span>\${p.measurement_id || ''}</span>
          <a href="\${reportUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation()">GA4 Report</a>
        </div>
        \${createTs ? \`<div class="when" title="\${p.create_time}">Created \${createTs}</div>\` : ''}
      </div>\`;
  }).join('');
}

function renderTable(data, win) {
  const thead = qs('thead');
  const tbody = qs('tbody');

  const cols = [
    { label: 'Property', key: 'display_name' },
    { label: 'Domain', key: 'domain' },
    ...(win !== '30d' ? [
      { label: 'Sessions 7d', key: 'sessions_7d', num: true },
      { label: 'Users 7d', key: 'users_7d', num: true },
      { label: 'PVs 7d', key: 'pvs_7d', num: true },
    ] : []),
    ...(win !== '7d' ? [
      { label: 'Sessions 30d', key: 'sessions_30d', num: true },
      { label: 'Users 30d', key: 'users_30d', num: true },
      { label: 'PVs 30d', key: 'pvs_30d', num: true },
    ] : []),
    { label: 'Measurement ID', key: 'measurement_id' },
    { label: 'Created', key: 'create_time' },
    { label: 'GA4', key: '_link' },
  ];

  // Fix B: wire th onclick for real column sort
  thead.innerHTML = '<tr>' + cols.map(c => {
    const isActive = sortState.col === c.key;
    const arrow = isActive ? (sortState.dir === 1 ? ' ▲' : ' ▼') : ' ⇅';
    const activeClass = isActive ? ' class="sorted"' : '';
    return \`<th\${activeClass} onclick="tableSort('\${c.key}')">\${c.label}<span class="sort-arrow">\${arrow}</span></th>\`;
  }).join('') + '</tr>';

  tbody.innerHTML = data.map(p => {
    const pid = p.property_id;
    const s7 = p.stats_7d || {};
    const s30 = p.stats_30d || {};
    const reportUrl = ga4ReportUrl(pid);
    const domainDisplay = p.domain ? p.domain.replace(/^https?:\\/\\//, '') : '-';
    const domainHtml = p.domain ? \`<a href="\${p.domain}" target="_blank" rel="noopener">\${domainDisplay}</a>\` : '-';
    const createStr = p.create_time ? fmtDate(p.create_time) : '-';
    // Duplicate row badge (Fix A)
    const dupLabel = p.duplicate_domain ? \` <span class="dup-badge" title="Non-canonical duplicate">⧉ dup</span>\` : '';
    const rowClass = p.duplicate_domain ? ' class="is-dup"' : '';

    const cells = [
      \`<td><a href="\${reportUrl}" target="_blank" rel="noopener">\${p.display_name || pid}</a>\${dupLabel}</td>\`,
      \`<td>\${domainHtml}</td>\`,
      ...(win !== '30d' ? [
        \`<td class="num">\${fmtNum(s7.sessions || 0)}</td>\`,
        \`<td class="num">\${fmtNum(s7.activeUsers || 0)}</td>\`,
        \`<td class="num">\${fmtNum(s7.screenPageViews || 0)}</td>\`,
      ] : []),
      ...(win !== '7d' ? [
        \`<td class="num">\${fmtNum(s30.sessions || 0)}</td>\`,
        \`<td class="num">\${fmtNum(s30.activeUsers || 0)}</td>\`,
        \`<td class="num">\${fmtNum(s30.screenPageViews || 0)}</td>\`,
      ] : []),
      \`<td class="no-data">\${p.measurement_id || '-'}</td>\`,
      \`<td title="\${p.create_time || ''}">\${createStr}</td>\`,
      \`<td><a href="\${reportUrl}" target="_blank" rel="noopener">Open</a></td>\`,
    ];
    return \`<tr\${rowClass}>\` + cells.join('') + '</tr>';
  }).join('');
}

// --- Fix B: table column sort ---
// Map col key → a comparable value for a property row.
function colValue(p, key) {
  const s7 = p.stats_7d || {};
  const s30 = p.stats_30d || {};
  switch (key) {
    case 'sessions_7d':    return s7.sessions || 0;
    case 'users_7d':       return s7.activeUsers || 0;
    case 'pvs_7d':         return s7.screenPageViews || 0;
    case 'sessions_30d':   return s30.sessions || 0;
    case 'users_30d':      return s30.activeUsers || 0;
    case 'pvs_30d':        return s30.screenPageViews || 0;
    case 'create_time':    return p.create_time || '';
    case 'measurement_id': return (p.measurement_id || '').toLowerCase();
    case 'display_name':   return (p.display_name || '').toLowerCase();
    case 'domain':         return (p.domain || '').toLowerCase();
    case '_link':          return p.property_id || '';
    default:               return (p[key] || '').toString().toLowerCase();
  }
}

// Numeric keys sort numerically; everything else lexicographically.
const NUMERIC_COLS = new Set(['sessions_7d','users_7d','pvs_7d','sessions_30d','users_30d','pvs_30d']);

function sortByState(data) {
  if (!sortState.col) return data;
  const key = sortState.col;
  const dir = sortState.dir;
  return [...data].sort((a, b) => {
    const va = colValue(a, key), vb = colValue(b, key);
    if (NUMERIC_COLS.has(key)) return (va - vb) * dir;
    return va.localeCompare(vb) * dir;
  });
}

function tableSort(key) {
  if (sortState.col === key) {
    sortState.dir = -sortState.dir; // toggle direction
  } else {
    sortState.col = key;
    sortState.dir = -1; // default: descending for numeric, A→Z for text
    if (!NUMERIC_COLS.has(key)) sortState.dir = 1;
  }
  const win = qs('window').value;
  const filtered = filterData(sortByState(ALL));
  renderTable(filtered, win);
}

// --- Boot ---
async function load() {
  try {
    const [dataRes, statusRes] = await Promise.all([
      fetch('/api/data'),
      fetch('/api/status'),
    ]);
    const data = await dataRes.json();
    const status = await statusRes.json();

    if (data.error) {
      qs('loading').textContent = 'Error: ' + data.error;
      return;
    }

    ALL = data.properties || [];
    qs('loading').style.display = 'none';

    const age = status.fetched_at
      ? Math.round((Date.now() - new Date(status.fetched_at)) / 60000) + ' min ago'
      : 'unknown';
    qs('meta').innerHTML =
      \`<span>\${status.property_count}</span> properties | \` +
      \`cache refreshed <span>\${age}</span> | \` +
      \`\${status.error_count} errors | $0 cost\`;

    applyPrefs();
    render();
  } catch (e) {
    qs('loading').textContent = 'Failed to load: ' + e.message;
  }
}

load();
</script>
</body>
</html>`;
}