← back to Ga Allsites

server.py

713 lines

#!/usr/bin/env python3
"""GA All-Sites dashboard — standing local server (DTD verdict 2026-08-04, Option 1).
Multi-view app: left icon-tab sidebar → Sites table / Countries / Visuals / Live.
Reads cache/*.json (never calls the GA Data API on page load); realtime is on-demand.
Basic-auth admin/DW2024!. Zero external deps (stdlib + google libs for realtime).
"""
from __future__ import annotations

import base64
import json
import os
import subprocess
import sys
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

HERE = Path(__file__).parent
CACHE = HERE / "cache" / "data.json"
COUNTRIES = HERE / "cache" / "countries.json"
KEYWORDS = HERE / "cache" / "keywords.json"
KEY = Path.home() / ".config" / "ga-analytics-agent" / "service-account.json"
# Default 9782 (not 9780): a fresh checkout with no .port must NOT land on 9780,
# which ventura-corridor owns on Kamatera — that collision caused the 2026-08 crash-loop.
PORT = int((HERE / ".port").read_text().strip()) if (HERE / ".port").exists() else 9782
USER, PW = "admin", "DW2024!"

_refresh_lock = threading.Lock()
_refreshing = {"on": False}
_live_cache = {"ts": 0, "data": None}
_live_lock = threading.Lock()
_LIVE_TTL = 12


def run_etl():
    with _refresh_lock:
        if _refreshing["on"]:
            return
        _refreshing["on"] = True
    try:
        subprocess.run([sys.executable, str(HERE / "etl.py")], cwd=str(HERE),
                       capture_output=True, timeout=300)
    finally:
        _refreshing["on"] = False


def fetch_live():
    now = _time.time()
    with _live_lock:
        if _live_cache["data"] and now - _live_cache["ts"] < _LIVE_TTL:
            return _live_cache["data"]
    os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(KEY)
    from google.analytics.data_v1beta import BetaAnalyticsDataClient
    from google.analytics.data_v1beta.types import Metric, RunRealtimeReportRequest
    client = BetaAnalyticsDataClient()
    props = []
    if CACHE.exists():
        d = json.loads(CACHE.read_text())
        props = [{"property": r["property"], "property_id": r["property_id"],
                  "account": r.get("account", "")} for r in d.get("rows", [])]

    def one(p):
        try:
            resp = client.run_realtime_report(RunRealtimeReportRequest(
                property=f"properties/{p['property_id']}",
                metrics=[Metric(name="activeUsers")]))
            active = int(resp.rows[0].metric_values[0].value) if resp.rows else 0
        except Exception:
            active = 0
        return {**p, "active": active}

    with ThreadPoolExecutor(max_workers=12) as ex:
        rows = list(ex.map(one, props))
    rows.sort(key=lambda r: r["active"], reverse=True)
    data = {"generated_at": datetime.now(timezone.utc).isoformat(),
            "total_active": sum(r["active"] for r in rows),
            "active_sites": sum(1 for r in rows if r["active"] > 0),
            "property_count": len(rows), "rows": rows}
    with _live_lock:
        _live_cache["ts"] = _time.time()
        _live_cache["data"] = data
    return data


PAGE = r"""<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GA · All Sites</title>
<style>
 :root{--fs:13px;--pad:6px}
 *{box-sizing:border-box}
 body{margin:0;font:var(--fs)/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1a1a1a;background:#f6f7f9;display:flex;min-height:100vh}
 /* sidebar */
 .side{width:78px;background:#0f1115;color:#aeb6c2;display:flex;flex-direction:column;align-items:stretch;padding:8px 0;position:sticky;top:0;height:100vh;flex:0 0 78px}
 .side .brand{color:#fff;font-weight:700;font-size:11px;text-align:center;padding:8px 4px 14px;letter-spacing:.5px;border-bottom:1px solid #23262d;margin-bottom:6px}
 .side button{background:none;border:0;color:inherit;cursor:pointer;padding:12px 4px;display:flex;flex-direction:column;align-items:center;gap:5px;font-size:10px;border-left:3px solid transparent}
 .side button .ic{font-size:20px;line-height:1}
 .side button:hover{background:#1a1d24;color:#fff}
 .side button.on{background:#171a20;color:#fff;border-left-color:#e74c3c}
 .main{flex:1;min-width:0;display:flex;flex-direction:column}
 header{position:sticky;top:0;z-index:5;background:#111;color:#fff;padding:12px 18px;display:flex;flex-wrap:wrap;gap:14px;align-items:center}
 header h1{font-size:15px;margin:0;font-weight:600}
 header .stat{font-variant-numeric:tabular-nums;color:#cfe;font-size:13px}
 header .muted{color:#9aa}
 .winsel{display:inline-flex;border:1px solid #3a3f4a;border-radius:8px;overflow:hidden}
 .winsel button{background:#1a1d24;color:#aeb6c2;border:0;padding:5px 13px;font-size:12px;cursor:pointer}
 .winsel button:hover{color:#fff}
 .winsel button.on{background:#e74c3c;color:#fff;font-weight:700}
 .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:10px 18px;background:#fff;border-bottom:1px solid #e6e8ec}
 .bar input[type=search]{padding:6px 10px;border:1px solid #cbd0d6;border-radius:6px;min-width:200px;font-size:13px}
 .bar label{font-size:12px;color:#555;display:inline-flex;gap:4px;align-items:center;cursor:pointer}
 .bar button{padding:6px 12px;border:1px solid #cbd0d6;background:#fafbfc;border-radius:6px;cursor:pointer;font-size:12px}
 .bar button:hover{background:#eef1f4}
 .cols{display:flex;flex-wrap:wrap;gap:2px 10px;padding:6px 18px;background:#fbfcfd;border-bottom:1px solid #eef0f3;font-size:11px;color:#667}
 .cols b{color:#333;margin-right:4px}
 .view{padding:0 0 40px}
 .view[hidden]{display:none}
 table{border-collapse:collapse;width:100%;background:#fff}
 thead th{position:sticky;top:44px;background:#f2f4f7;text-align:right;padding:var(--pad) 12px;border-bottom:2px solid #dde1e6;font-weight:600;cursor:pointer;white-space:nowrap;user-select:none}
 thead th.l{text-align:left}
 thead th:hover{background:#e9edf2}
 tbody td{padding:var(--pad) 12px;text-align:right;border-bottom:1px solid #f0f1f4;font-variant-numeric:tabular-nums;white-space:nowrap}
 tbody td.l{text-align:left}
 tbody tr:hover{background:#f8fafc}
 a{color:#1558d6;text-decoration:none}a:hover{text-decoration:underline}
 .z{color:#b8bec6}.acct{font-size:11px;color:#7a828c}
 .badge{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:600}
 .fresh{background:#e4f5e9;color:#1a7a3c}.stale{background:#fdecc8;color:#8a5a00}
 tfoot td{padding:8px 12px;font-weight:700;border-top:2px solid #dde1e6;text-align:right;background:#f7f9fb}
 tfoot td.l{text-align:left}
 .err{color:#c0392b;font-size:11px}
 /* charts */
 .panel{padding:18px}
 .card{background:#fff;border:1px solid #e6e8ec;border-radius:10px;padding:18px;margin-bottom:18px}
 .card h2{margin:0 0 12px;font-size:14px}
 .piewrap{display:flex;gap:26px;align-items:center;flex-wrap:wrap}
 .pie{width:220px;height:220px;border-radius:50%;flex:0 0 auto}
 .legend{list-style:none;margin:0;padding:0;font-size:13px}
 .legend li{display:flex;align-items:center;gap:8px;padding:3px 0}
 .legend .sw{width:12px;height:12px;border-radius:3px;flex:0 0 auto}
 .legend .v{margin-left:auto;font-variant-numeric:tabular-nums;color:#555;padding-left:18px}
 /* pyramid */
 .pyr{display:flex;flex-direction:column;align-items:center;gap:4px}
 .pyr .row{display:flex;align-items:center;justify-content:center;gap:8px;width:100%}
 .pyr .bar{background:linear-gradient(90deg,#5b8def,#8a5bef);color:#fff;text-align:center;border-radius:4px;padding:5px 8px;font-size:12px;white-space:nowrap;overflow:hidden}
 .pyr .lbl{font-size:12px;color:#333;min-width:150px;text-align:right}
 .pyr .val{font-size:12px;color:#666;min-width:60px;text-align:left;font-variant-numeric:tabular-nums}
 svg text{font:12px -apple-system,sans-serif}
 /* KPI cards */
 .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:18px}
 .kpi{background:#fff;border:1px solid #e6e8ec;border-radius:10px;padding:16px}
 .kpi .kv{font-size:25px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1}
 .kpi .kl{font-size:12px;color:#667;margin-top:5px}
 /* liquid gauge orbs */
 .orbs{display:flex;flex-wrap:wrap;gap:24px;justify-content:center;padding:10px 0 6px}
 .orb{width:190px;text-align:center}
 .orb svg{width:170px;height:170px;display:block;margin:0 auto}
 .orb .olabel{font-size:13px;color:#555;margin-top:8px;font-weight:600}
 .orb .osub{font-size:11px;color:#8a8f98}
 .wave{animation:wavemove linear infinite}
 @keyframes wavemove{from{transform:translateX(0)}to{transform:translateX(-160px)}}
 .orb .ocenter{font-size:26px;font-weight:800;fill:#12324f;font-variant-numeric:tabular-nums}
 /* live moving graphics */
 @keyframes pulse{0%{transform:scale(1);opacity:.9}70%{transform:scale(2.6);opacity:0}100%{opacity:0}}
 .livehero{position:relative;padding:22px 26px;background:linear-gradient(120deg,#180d12,#2a1019);color:#fff;display:flex;align-items:center;gap:22px;flex-wrap:wrap}
 .livedot{width:13px;height:13px;border-radius:50%;background:#e74c3c;position:relative;flex:0 0 auto}
 .livedot::after{content:'';position:absolute;inset:0;border-radius:50%;background:#e74c3c;animation:pulse 1.6s ease-out infinite}
 .bignum{font-size:54px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
 .livelbl{font-size:13px;color:#f0a0b0;margin-top:5px}
 .lrow{display:grid;grid-template-columns:180px 1fr;align-items:center;gap:12px;margin:6px 0;transition:transform .5s}
 .lrow .nm{font-size:12px;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#333}
 .lbar{height:26px;border-radius:6px;background:linear-gradient(90deg,#e74c3c,#ff9a6b);color:#fff;display:flex;align-items:center;padding:0 9px;font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;min-width:26px;transition:width .9s cubic-bezier(.34,1.4,.5,1)}
</style></head><body>
<nav class="side" id="side">
 <div class="brand">GA<br>FLEET</div>
 <button data-tab="sites" class="on"><span class="ic">📊</span>Sites</button>
 <button data-tab="global"><span class="ic">🌊</span>Global</button>
 <button data-tab="charts"><span class="ic">📈</span>Charts</button>
 <button data-tab="keywords"><span class="ic">🔎</span>Keywords</button>
 <button data-tab="visuals"><span class="ic">🔺</span>Visuals</button>
 <button data-tab="countries"><span class="ic">🌍</span>Country</button>
 <button data-tab="map"><span class="ic">🗺️</span>Map</button>
 <button data-tab="live"><span class="ic">🔴</span>Live</button>
</nav>
<div class="main">
 <header>
  <h1 id="htitle">All Sites</h1>
  <span class="winsel" id="winsel" title="Stats window — applies to Sites, Global, Charts, Visuals and Keywords"></span>
  <span class="stat" id="hstat">loading…</span>
  <span id="hbadge"></span>
  <label style="margin-left:auto;color:#bcd;font-size:12px;display:inline-flex;gap:5px;align-items:center;cursor:pointer" title="Hide the main DW store (bot-inflated) so the sister-site fleet is visible"><input type="checkbox" id="exstore"> exclude main store</label>
  <span class="stat muted" id="hgen"></span>
 </header>

 <!-- SITES -->
 <div class="view" id="v-sites">
  <div class="bar">
   <input type="search" id="q" placeholder="filter sites…" autocomplete="off">
   <label>density <input type="range" id="dens" min="10" max="18" value="13"></label>
   <label><input type="checkbox" id="onlyactive"> only sites with traffic</label>
   <button id="refresh">↻ Refresh data</button>
   <span class="muted" id="rmsg"></span>
  </div>
  <div class="cols" id="cols"></div>
  <table id="t"><thead><tr id="hr"></tr></thead><tbody id="tb"></tbody><tfoot><tr id="tf"></tr></tfoot></table>
 </div>

 <!-- GLOBAL liquid gauges -->
 <div class="view panel" id="v-global" hidden>
  <div class="card"><h2>Fleet at a glance — live liquid gauges</h2>
   <div class="orbs" id="orbs"></div>
   <div class="kpis" id="gkpis" style="margin-top:18px"></div>
  </div>
 </div>

 <!-- CHARTS overview -->
 <div class="view panel" id="v-charts" hidden>
  <div class="kpis" id="kpis"></div>
  <div class="card"><h2 id="dbtitle">Top domains by sessions</h2><div id="domainbars"></div></div>
  <div class="card"><h2>Sessions by country — 30 days</h2><div class="piewrap"><div class="pie" id="cpie"></div><ul class="legend" id="cpielegend"></ul></div></div>
 </div>

 <!-- KEYWORDS -->
 <div class="view panel" id="v-keywords" hidden>
  <div class="kpis" id="kwkpis"></div>
  <div class="card" id="kwsetup" hidden></div>
  <div class="card"><h2 id="kwqtitle">Top search queries</h2>
   <table><thead><tr><th class="l">Query</th><th>Clicks</th><th>Impressions</th><th>CTR</th><th>Avg pos</th></tr></thead>
    <tbody id="kwtb"></tbody></table></div>
  <div class="card"><h2 id="kwgptitle">GSC grant priority — ranked by organic search (365d)</h2>
   <p class="acct" id="kwgpsub"></p>
   <table id="kwgptbl"><thead><tr>
     <th class="l" data-k="property">Site</th><th data-k="organic_365d">Organic 365d</th>
     <th data-k="total_365d">Total 365d</th><th data-k="organic_share">Organic %</th>
     <th class="l" data-k="status">Status</th></tr></thead>
    <tbody id="kwgptb"></tbody></table></div>
  <div class="card"><h2 id="kwchtitle">Traffic by channel</h2>
   <div class="piewrap"><div class="pie" id="kwpie"></div><ul class="legend" id="kwpielegend"></ul></div></div>
  <div class="card"><h2 id="kworgtitle">Top sites by Organic Search sessions</h2><div id="kworg"></div></div>
 </div>

 <!-- VISUALS -->
 <div class="view panel" id="v-visuals" hidden>
  <div class="card"><h2 id="mmtitle">Fleet mind map — top sites by sessions</h2><div id="mindmap"></div></div>
  <div class="card"><h2 id="pyrtitle">Traffic pyramid — top 10 sites</h2><div class="pyr" id="pyr"></div></div>
 </div>

 <!-- COUNTRIES -->
 <div class="view panel" id="v-countries" hidden>
  <div class="card"><h2>Where your users are — fleet-wide, last 30 days</h2>
   <div class="piewrap"><div class="pie" id="pie"></div><ul class="legend" id="pielegend"></ul></div></div>
  <div class="card"><h2>All countries</h2>
   <table><thead><tr><th class="l">Country</th><th>Sessions</th><th>Users</th><th>Share</th></tr></thead>
    <tbody id="ctb"></tbody></table></div>
 </div>

 <!-- MAP -->
 <div class="view panel" id="v-map" hidden>
  <div class="card"><h2>User world map — sessions by country, last 30 days</h2>
   <div id="mapwrap" style="position:relative"><div id="map"></div>
    <div id="maptip" style="position:absolute;pointer-events:none;background:#111;color:#fff;padding:3px 8px;border-radius:5px;font-size:12px;display:none;white-space:nowrap"></div></div>
   <div id="maplegend" style="margin-top:10px;font-size:12px;color:#667"></div></div>
 </div>

 <!-- LIVE (moving graphics) -->
 <div class="view" id="v-live" hidden>
  <div class="livehero">
   <span class="livedot"></span>
   <div><div class="bignum" id="livetot">—</div><div class="livelbl">active users on the fleet · last 30 min</div></div>
   <span id="livesites" style="margin-left:auto;font-size:12px;color:#c9a">connecting…</span>
  </div>
  <div class="panel">
   <div class="card"><h2>Live pulse — active users over this session</h2><div id="livespark"></div></div>
   <div class="card"><h2>Active right now — by site</h2><div id="livebars"></div></div>
  </div>
 </div>
</div>

<script>window.__BOOT__=/*BOOTDATA*/null/*/BOOTDATA*/;</script>
<script>
const PAL=['#5b8def','#e74c3c','#27ae60','#f39c12','#9b59b6','#16a085','#e67e22','#2c3e50','#c0392b','#7f8c8d'];
const fmtDurP=s=>{s=Math.round(+s||0);return `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`;};
const fmtDur=s=>{s=Math.round(+s||0);return s?fmtDurP(s):'<span class=z>0:00</span>';};
/* Time window: Day / Week / Month / Year → d1 / d7 / d30 / d365 buckets.
   Metric columns use 'm.<metric>' keys resolved against the selected window,
   so one column set serves all four windows. */
const WINS=[['d1','Day'],['d7','Week'],['d30','Month'],['d365','Year']];
const WLBL=k=>(WINS.find(w=>w[0]===k)||['','?'])[1];
let WIN=localStorage.gaWin||'d30';if(!WINS.some(w=>w[0]===WIN))WIN='d30';
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const dig=(o,k)=>k.split('.').reduce((a,x)=>a?.[x],o);
const val=(r,k)=>k.startsWith('m.')?dig(r,WIN+'.'+k.slice(2)):dig(r,k);
const COLS=[
 {k:'property',l:'Site',t:'l',def:1,fmt:r=>`<a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/reports/intelligenthome" target="_blank" rel="noopener noreferrer">${esc(r.property||'?')}</a>${r.error?` <span class="err" title="${esc(r.error)}">⚠</span>`:''}`},
 {k:'account',l:'Account',t:'l',def:1,cls:'acct',fmt:r=>r.account.includes('APPS')?'APPS':esc(r.account)},
 {k:'top_country',l:'Top country',t:'l',def:1,cls:'acct',fmt:r=>esc(r.top_country||'—')},
 {k:'m.sessions',l:'Sessions',def:1},
 {k:'m.activeUsers',l:'Users',def:1},
 {k:'m.screenPageViews',l:'Views',def:1},
 {k:'m.conversions',l:'Conv',def:0},
 {k:'m.averageSessionDuration',l:'Time',def:1,notot:1,fmt:r=>fmtDur(val(r,'m.averageSessionDuration'))},
];
const num=x=>{x=+x||0;return x?Math.round(x).toLocaleString():'<span class=z>0</span>'};
let DATA={rows:[]},CDATA=null,KDATA=null;
const STORE_ID='294178030';
let exStore=localStorage.gaExStore==='1';
function activeRows(){return exStore?DATA.rows.filter(r=>r.property_id!==STORE_ID):DATA.rows;}
let sortK=localStorage.gaSortK2||'m.sessions',sortDir=+(localStorage.gaSortDir||-1);
// field selector: default from COLS[].def, then user override persists
let hidden=new Set(JSON.parse(localStorage.gaHidden2||'null')||COLS.filter(c=>!c.def).map(c=>c.k));
function renderWinSel(){
 document.getElementById('winsel').innerHTML=WINS.map(([k,l])=>`<button data-w="${k}" class="${k===WIN?'on':''}">${l}</button>`).join('');
 document.querySelectorAll('#winsel button').forEach(b=>b.onclick=()=>{
   WIN=b.dataset.w;localStorage.gaWin=WIN;renderWinSel();header();
   const cur=document.querySelector('.side button.on').dataset.tab;
   ({sites:renderTable,global:renderGlobal,charts:renderCharts,visuals:renderVisuals,keywords:renderKeywords}[cur]||renderTable)();});
}

/* ---- field selector (persists on load) ---- */
function renderCols(){
 document.getElementById('cols').innerHTML='<b>Fields on load:</b>'+COLS.map(c=>
   `<label><input type=checkbox data-c="${c.k}" ${hidden.has(c.k)?'':'checked'}>${c.l}</label>`).join(' ')+
   ' <a href="#" id="resetcols" style="margin-left:8px">reset</a>';
 document.querySelectorAll('#cols input').forEach(cb=>cb.onchange=()=>{
   cb.checked?hidden.delete(cb.dataset.c):hidden.add(cb.dataset.c);
   localStorage.gaHidden=JSON.stringify([...hidden]);renderTable();});
 document.getElementById('resetcols').onclick=e=>{e.preventDefault();hidden=new Set(COLS.filter(c=>!c.def).map(c=>c.k));localStorage.removeItem('gaHidden');renderCols();renderTable();};
}
const vis=()=>COLS.filter(c=>!hidden.has(c.k));

/* ---- SITES table ---- */
function renderTable(){
 const q=document.getElementById('q').value.toLowerCase();
 const onlyA=document.getElementById('onlyactive').checked;
 let rows=activeRows().filter(r=>(r.property||'').toLowerCase().includes(q));
 if(onlyA)rows=rows.filter(r=>val(r,'m.sessions')>0);
 rows.sort((a,b)=>{const x=val(a,sortK),y=val(b,sortK);
   return typeof x==='string'?sortDir*String(x).localeCompare(String(y)):sortDir*((+x||0)-(+y||0));});
 const wl=WLBL(WIN);
 document.getElementById('hr').innerHTML=vis().map(c=>`<th class="${c.t==='l'?'l':''}" data-k="${c.k}">${c.k.startsWith('m.')?wl+' '+c.l:c.l}${sortK===c.k?(sortDir<0?' ▼':' ▲'):''}</th>`).join('');
 document.querySelectorAll('#hr th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;
   if(sortK===k)sortDir*=-1;else{sortK=k;sortDir=(k==='property'||k==='account')?1:-1;}
   localStorage.gaSortK2=sortK;localStorage.gaSortDir=sortDir;renderTable();});
 document.getElementById('tb').innerHTML=rows.map(r=>'<tr>'+vis().map(c=>{
   const v=c.fmt?c.fmt(r):num(val(r,c.k));return `<td class="${c.t==='l'?'l':''} ${c.cls||''}">${v}</td>`;}).join('')+'</tr>').join('');
 const tot=k=>rows.reduce((s,r)=>s+(+val(r,k)||0),0);
 document.getElementById('tf').innerHTML=vis().map((c,i)=>i===0?`<td class="l">Σ ${rows.length} sites</td>`:(c.t==='l'?'<td class="l"></td>':c.notot?'<td></td>':`<td>${num(tot(c.k))}</td>`)).join('');
}

/* ---- GLOBAL: liquid-fill gauge orbs ---- */
function liquidOrb(level,center,label,sub,color,i){
 level=Math.max(0,Math.min(1,+level||0));
 const R=80,cx=85,cy=85,top=(cy-R)+(2*R)*(1-level),amp=5,wl=80;
 let d='M0 '+top.toFixed(1);
 for(let x=0;x<=320;x+=8){d+=' L'+x+' '+(top+amp*Math.sin((x/wl)*Math.PI*2)).toFixed(1);}
 d+=' L320 170 L0 170 Z';
 const id='clip'+i,dur=(3+i*0.6).toFixed(1);
 return `<div class="orb"><svg viewBox="0 0 170 170">
  <defs><clipPath id="${id}"><circle cx="${cx}" cy="${cy}" r="${R}"/></clipPath></defs>
  <circle cx="${cx}" cy="${cy}" r="${R+3}" fill="#f2f6fb" stroke="#dbe3ec" stroke-width="2"/>
  <g clip-path="url(#${id})">
   <path class="wave" d="${d}" fill="${color}" opacity=".85" style="animation-duration:${dur}s"/>
   <path class="wave" d="${d}" fill="${color}" opacity=".4" style="animation-duration:${(+dur+1.6).toFixed(1)}s;animation-direction:reverse"/>
  </g>
  <circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="#c3d0de" stroke-width="1"/>
  <text class="ocenter" x="${cx}" y="${cy+9}" text-anchor="middle">${center}</text>
 </svg><div class="olabel">${esc(label)}</div><div class="osub">${esc(sub)}</div></div>`;
}
async function renderGlobal(){
 await ensureCountries();
 const wl=WLBL(WIN);
 const rows=activeRows(),total=rows.length,active=rows.filter(r=>val(r,'m.sessions')>0).length;
 const tw=rows.reduce((s,r)=>s+(val(r,'m.sessions')||0),0),ty=rows.reduce((s,r)=>s+(dig(r,'d365.sessions')||0),0);
 const pv=rows.reduce((s,r)=>s+(val(r,'m.screenPageViews')||0),0);
 const wsum=rows.reduce((s,r)=>s+(val(r,'m.averageSessionDuration')||0)*(val(r,'m.sessions')||0),0),wavg=tw?wsum/tw:0;
 const topC=((CDATA&&CDATA.rows)||[])[0]||{country:'—',share:0};
 document.getElementById('orbs').innerHTML=[
  liquidOrb(active/Math.max(1,total),active,'Sites with traffic','of '+total+' total · '+wl.toLowerCase(),'#27ae60',0),
  liquidOrb((topC.share||0)/100,(topC.share||0)+'%','Top country',topC.country+' · 30d','#5b8def',1),
  liquidOrb(ty?tw/ty:0,Math.round(tw).toLocaleString(),wl+' sessions','of year ('+Math.round(ty).toLocaleString()+')','#8a5bef',2),
  liquidOrb(Math.min(1,wavg/90),fmtDurP(wavg),'Avg time on site',wl.toLowerCase()+' · vs 1:30 benchmark','#e67e22',3),
 ].join('');
 const kpi=[['Domains',total],['Sessions · '+wl,Math.round(tw).toLocaleString()],['Sessions · Year',Math.round(ty).toLocaleString()],['Pageviews · '+wl,Math.round(pv).toLocaleString()],['Top country',topC.country]];
 document.getElementById('gkpis').innerHTML=kpi.map((k,i)=>`<div class="kpi"><div class="kv" style="color:${PAL[i%PAL.length]}">${k[1]}</div><div class="kl">${k[0]}</div></div>`).join('');
}

/* ---- VISUALS ---- */
function renderVisuals(){
 const sorted=[...DATA.rows].sort((a,b)=>(val(b,'m.sessions')||0)-(val(a,'m.sessions')||0));
 const top=sorted.filter(r=>val(r,'m.sessions')>0).slice(0,10);
 const max=top.length?val(top[0],'m.sessions'):1;
 document.getElementById('pyrtitle').textContent=`Traffic pyramid — top 10 sites (${WLBL(WIN)} sessions)`;
 document.getElementById('mmtitle').textContent=`Fleet mind map — top sites by ${WLBL(WIN)} sessions`;
 document.getElementById('pyr').innerHTML=top.map(r=>{
   const s=val(r,'m.sessions');const w=Math.max(6,Math.round(100*s/max));
   return `<div class="row"><span class="lbl">${esc(r.property)}</span><span class="bar" style="width:${w}%">${Math.round(s)}</span><span class="val"></span></div>`;}).join('')||'<div class="z">no traffic yet</div>';
 // mind map (SVG radial): center → top 8 sites
 const nodes=sorted.filter(r=>val(r,'m.sessions')>0).slice(0,8);
 const W=820,H=460,cx=W/2,cy=H/2,R=170;
 const t30=DATA.rows.reduce((s,r)=>s+(val(r,'m.sessions')||0),0);
 let svg=`<svg viewBox="0 0 ${W} ${H}" width="100%" style="max-height:480px">`;
 nodes.forEach((n,i)=>{const a=-Math.PI/2+2*Math.PI*i/nodes.length;const x=cx+R*Math.cos(a),y=cy+R*Math.sin(a);
   svg+=`<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}" stroke="#cdd4dd" stroke-width="2"/>`;});
 nodes.forEach((n,i)=>{const a=-Math.PI/2+2*Math.PI*i/nodes.length;const x=cx+R*Math.cos(a),y=cy+R*Math.sin(a);
   const c=PAL[i%PAL.length];const lbl=(n.property||'').slice(0,16);const s=Math.round(val(n,'m.sessions'));
   svg+=`<g><rect x="${x-64}" y="${y-16}" width="128" height="32" rx="8" fill="#fff" stroke="${c}" stroke-width="2"/>`+
        `<text x="${x}" y="${y-1}" text-anchor="middle" fill="#1a1a1a">${esc(lbl)}</text>`+
        `<text x="${x}" y="${y+12}" text-anchor="middle" fill="${c}" font-weight="700">${s} sess</text></g>`;});
 svg+=`<circle cx="${cx}" cy="${cy}" r="52" fill="#0f1115"/>`+
      `<text x="${cx}" y="${cy-6}" text-anchor="middle" fill="#fff" font-weight="700">FLEET</text>`+
      `<text x="${cx}" y="${cy+12}" text-anchor="middle" fill="#8fa">${DATA.rows.length} sites</text>`+
      `<text x="${cx}" y="${cy+28}" text-anchor="middle" fill="#8fa">${Math.round(t30)} sess</text></svg>`;
 document.getElementById('mindmap').innerHTML=svg;
}

/* ---- COUNTRIES + shared pie ---- */
async function ensureCountries(){if(CDATA)return;try{const r=await fetch(location.origin+'/api/countries');CDATA=await r.json();}catch(e){CDATA={rows:[]};}}
function renderPie(pieId,legendId){
 const rows=(CDATA&&CDATA.rows)||[];const tot=(CDATA&&CDATA.total_sessions)||rows.reduce((s,r)=>s+r.sessions,0)||1;
 const top=rows.slice(0,8),otherS=rows.slice(8).reduce((s,r)=>s+r.sessions,0);
 const segs=top.map((r,i)=>({name:r.country,v:r.sessions,c:PAL[i%PAL.length]}));
 if(otherS>0)segs.push({name:'Other',v:otherS,c:'#bdc3c7'});
 let acc=0;const stops=segs.map(s=>{const a=100*acc/tot,b=100*(acc+s.v)/tot;acc+=s.v;return `${s.c} ${a.toFixed(2)}% ${b.toFixed(2)}%`;}).join(',');
 const pie=document.getElementById(pieId);if(pie)pie.style.background=`conic-gradient(${stops})`;
 const lg=document.getElementById(legendId);if(lg)lg.innerHTML=segs.map(s=>`<li><span class="sw" style="background:${s.c}"></span>${esc(s.name)}<span class="v">${Math.round(s.v)} · ${(100*s.v/tot).toFixed(1)}%</span></li>`).join('');
}
async function renderCountries(){
 await ensureCountries();
 renderPie('pie','pielegend');
 document.getElementById('ctb').innerHTML=(CDATA.rows||[]).map(r=>`<tr><td class="l">${esc(r.country)}</td><td>${Math.round(r.sessions).toLocaleString()}</td><td>${Math.round(r.users).toLocaleString()}</td><td>${r.share}%</td></tr>`).join('');
}

/* ---- CHARTS overview (all domains on one page) ---- */
async function renderCharts(){
 await ensureCountries();
 const wl=WLBL(WIN);
 const rows=activeRows();
 const active=rows.filter(r=>val(r,'m.sessions')>0).length;
 const tw=rows.reduce((s,r)=>s+(val(r,'m.sessions')||0),0),ty=rows.reduce((s,r)=>s+(dig(r,'d365.sessions')||0),0);
 const pv=rows.reduce((s,r)=>s+(val(r,'m.screenPageViews')||0),0);
 const wsum=rows.reduce((s,r)=>s+(val(r,'m.averageSessionDuration')||0)*(val(r,'m.sessions')||0),0);
 const wavg=tw?wsum/tw:0;
 const topC=((CDATA&&CDATA.rows)||[])[0]||{country:'—',share:0};
 const kpi=[['Domains',rows.length],['Sessions · '+wl,Math.round(tw).toLocaleString()],['Sessions · Year',Math.round(ty).toLocaleString()],
   ['Pageviews · '+wl,Math.round(pv).toLocaleString()],['Avg time on site',fmtDurP(wavg)],['Top country',topC.country+(topC.share?' · '+topC.share+'%':'')],['Sites w/ traffic',active+' / '+rows.length]];
 document.getElementById('kpis').innerHTML=kpi.map((k,i)=>`<div class="kpi"><div class="kv" style="color:${PAL[i%PAL.length]}">${k[1]}</div><div class="kl">${k[0]}</div></div>`).join('');
 document.getElementById('dbtitle').textContent=`Top domains by sessions — ${wl}`;
 const sorted=[...rows].sort((a,b)=>(val(b,'m.sessions')||0)-(val(a,'m.sessions')||0));
 const top=sorted.filter(r=>val(r,'m.sessions')>0).slice(0,15),mx=Math.max(1,...top.map(r=>val(r,'m.sessions')));
 document.getElementById('domainbars').innerHTML=top.map(r=>{const v=Math.round(val(r,'m.sessions'));
   return `<div class="lrow"><span class="nm"><a href="https://analytics.google.com/analytics/web/#/p${r.property_id}/reports/intelligenthome" target="_blank" rel="noopener noreferrer">${esc(r.property)}</a></span><div class="lbar" style="width:${Math.max(6,Math.round(100*v/mx))}%;background:linear-gradient(90deg,#5b8def,#8a5bef)">${v}</div></div>`;}).join('');
 renderPie('cpie','cpielegend');
}

/* ---- KEYWORDS (GSC queries + GA4 channels, per window) ---- */
async function ensureKeywords(){if(KDATA)return;try{KDATA=await(await fetch(location.origin+'/api/keywords')).json();}catch(e){KDATA={gsc_site_count:0,windows:{},channels:{},organic_sites:{}};}}
async function renderKeywords(){
 await ensureKeywords();
 const wl=WLBL(WIN);
 const wd=(KDATA.windows||{})[WIN]||{queries:[],totals:{}};
 const t=wd.totals||{};
 const ch=(KDATA.channels||{})[WIN]||{};
 const org=(KDATA.organic_sites||{})[WIN]||[];
 const orgTot=Math.round(ch['Organic Search']||0);
 const kpi=[['Search queries',(t.query_count||0).toLocaleString()],['Clicks · '+wl,(t.clicks||0).toLocaleString()],
   ['Impressions · '+wl,(t.impressions||0).toLocaleString()],['Organic sessions · '+wl,orgTot.toLocaleString()],
   ['GSC sites',KDATA.gsc_site_count||0]];
 document.getElementById('kwkpis').innerHTML=kpi.map((k,i)=>`<div class="kpi"><div class="kv" style="color:${PAL[i%PAL.length]}">${k[1]}</div><div class="kl">${k[0]}</div></div>`).join('');
 const setup=document.getElementById('kwsetup');
 if(!(KDATA.gsc_site_count>0)){
   setup.hidden=false;
   setup.innerHTML=`<h2>Unlock real search keywords (one-time, ~2 min)</h2>
    <p>Google only exposes organic search <b>queries</b> through Search Console — GA4 alone can't see them. The dashboard is fully wired for them; it just needs to be let in:</p>
    <ol style="line-height:1.9">
     <li>Open <a href="https://search.google.com/search-console" target="_blank" rel="noopener noreferrer">Search Console</a> with the Google account that owns your sites.</li>
     <li>Pick a property → ⚙ Settings → <b>Users and permissions</b> → <b>Add user</b>.</li>
     <li>Add <code>${esc(KDATA.sa_email||'the dashboard service account')}</code> (Restricted access is enough).</li>
     <li>Repeat per property — designerwallcoverings.com first. Queries appear on the next auto-refresh (≤30 min).</li>
    </ol>
    <p class="acct">Meanwhile the channel + organic-sessions stats below are live from GA4 — real search-traffic volume, just without the query strings.</p>`;
 } else setup.hidden=true;
 document.getElementById('kwqtitle').textContent=`Top search queries — ${wl}`;
 document.getElementById('kwtb').innerHTML=(wd.queries||[]).slice(0,200).map(r=>
   `<tr><td class="l">${esc(r.query)}</td><td>${(+r.clicks).toLocaleString()}</td><td>${(+r.impressions).toLocaleString()}</td><td>${r.ctr}%</td><td>${r.position}</td></tr>`).join('')
   ||`<tr><td class="l z" colspan="5" style="text-align:left;padding:14px">${KDATA.gsc_site_count>0?'no query data for this window yet':'no Search Console access yet — see the card above'}</td></tr>`;
 document.getElementById('kwchtitle').textContent=`Traffic by channel — ${wl} (GA4, fleet-wide)`;
 const segs=Object.entries(ch).sort((a,b)=>b[1]-a[1]);
 const tot=segs.reduce((s,x)=>s+x[1],0)||1;
 let acc=0;const col=(n,i)=>n==='Organic Search'?'#27ae60':PAL[i%PAL.length];
 const stops=segs.map((s,i)=>{const a=100*acc/tot,b=100*(acc+s[1])/tot;acc+=s[1];return `${col(s[0],i)} ${a.toFixed(2)}% ${b.toFixed(2)}%`;}).join(',');
 const pie=document.getElementById('kwpie');
 pie.style.background=segs.length?`conic-gradient(${stops})`:'#eef1f4';
 document.getElementById('kwpielegend').innerHTML=segs.map((s,i)=>
   `<li><span class="sw" style="background:${col(s[0],i)}"></span>${esc(s[0])}<span class="v">${Math.round(s[1]).toLocaleString()} · ${(100*s[1]/tot).toFixed(1)}%</span></li>`).join('')
   ||'<li class="z">channel data arrives on the next data refresh</li>';
 renderGrant();
 document.getElementById('kworgtitle').textContent=`Top sites by Organic Search sessions — ${wl}`;
 const mx=Math.max(1,...org.map(r=>r.sessions));
 document.getElementById('kworg').innerHTML=org.map(r=>
   `<div class="lrow"><span class="nm">${esc(r.property)}</span><div class="lbar" style="width:${Math.max(6,Math.round(100*r.sessions/mx))}%;background:linear-gradient(90deg,#27ae60,#7bd88a)">${Math.round(r.sessions)}</div></div>`).join('')
   ||'<div class="z" style="padding:14px">no organic-search sessions in this window yet</div>';
}
/* grant-priority table — organic-search-first, sortable, organic desc by default */
let KWGP={k:'organic_365d',dir:-1};
function grankOrder(r){return r.granted?0:(r.recommend?1:(!r.verifiable?2:3));}
function grankStatus(r){
 if(r.granted)return '<span style="color:#27ae60">✅ granted</span>';
 if(!r.verifiable)return '<span class="z">? verify domain</span>';
 if(!r.meets_floor)return '<span class="z">— below floor</span>';
 return '<span style="color:#2d7a34">▷ grant</span>';}
function renderGrant(){
 const gp=(KDATA.grant_priority||[]).slice();
 const q=gp.filter(r=>r.recommend).length;
 document.getElementById('kwgpsub').textContent=
   `GSC value tracks organic search, not total traffic — ranked by 365d organic sessions (floor ≥10). ${q} site${q===1?'':'s'} worth granting.`;
 const k=KWGP.k,dir=KWGP.dir;
 gp.sort((a,b)=>{if(k==='property')return dir*String(a.property).localeCompare(String(b.property));
   const x=k==='status'?grankOrder(a):(a[k]||0),y=k==='status'?grankOrder(b):(b[k]||0);return dir*(x-y);});
 document.querySelectorAll('#kwgptbl th').forEach(th=>{th.style.cursor='pointer';
   th.style.opacity=th.dataset.k===k?'1':'.7';
   th.onclick=()=>{KWGP.dir=(KWGP.k===th.dataset.k)?-KWGP.dir:-1;KWGP.k=th.dataset.k;renderGrant();};});
 document.getElementById('kwgptb').innerHTML=gp.map(r=>
   `<tr${r.granted?' style="opacity:.6"':''}><td class="l">${esc(r.property)}`+
   `${r.domain?` <span class="z" style="font-size:11px">${esc(r.domain)}</span>`:''}</td>`+
   `<td>${(r.organic_365d||0).toLocaleString()}</td><td>${(r.total_365d||0).toLocaleString()}</td>`+
   `<td>${r.organic_share}%</td><td class="l">${grankStatus(r)}</td></tr>`).join('')
   ||'<tr><td class="l z" colspan="5" style="padding:14px">organic-search ranking arrives on the next data refresh</td></tr>';
}

/* ---- MAP (choropleth, zero-dep SVG) ---- */
let WORLD=null;
async function renderMap(){
 await ensureCountries();
 if(!WORLD){try{WORLD=await(await fetch(location.origin+'/api/world')).json();}catch(e){document.getElementById('map').textContent='map data unavailable';return;}}
 const ALIAS={'USA':'United States','England':'United Kingdom','Turkey':'Türkiye','Czech Republic':'Czechia','Republic of Serbia':'Serbia'};
 const sess={};(CDATA.rows||[]).forEach(r=>sess[r.country]=r.sessions);
 const max=Math.max(1,...Object.values(sess));
 const W=960,H=480;
 const proj=([lo,la])=>[(lo+180)/360*W,(90-la)/180*H];
 const ring=r=>'M'+r.map(p=>{const[x,y]=proj(p);return x.toFixed(1)+','+y.toFixed(1);}).join('L')+'Z';
 const col=v=>{if(!v)return '#e9edf2';const t=0.15+0.85*Math.sqrt(v/max);
   return `rgb(${Math.round(220-200*t)},${Math.round(233-160*t)},${Math.round(251-120*t)})`;};
 let paths='';
 WORLD.features.forEach(f=>{
   const gn=f.properties.name,ga=ALIAS[gn]||gn,v=sess[ga]||0,g=f.geometry;
   const polys=g.type==='Polygon'?[g.coordinates]:g.type==='MultiPolygon'?g.coordinates:[];
   const d=polys.map(poly=>poly.map(ring).join('')).join('');
   if(d)paths+=`<path d="${d}" fill="${col(v)}" stroke="#fff" stroke-width="0.4" data-n="${esc(ga)}" data-v="${Math.round(v)}"/>`;
 });
 document.getElementById('map').innerHTML=`<svg viewBox="0 0 ${W} ${H}" width="100%" style="background:#eaf2fb;border-radius:8px">${paths}</svg>`;
 const tip=document.getElementById('maptip'),wrap=document.getElementById('mapwrap');
 document.querySelectorAll('#map path').forEach(p=>{
   p.onmousemove=e=>{tip.style.display='block';tip.textContent=`${p.dataset.n} — ${(+p.dataset.v).toLocaleString()} sess`;
     const b=wrap.getBoundingClientRect();tip.style.left=(e.clientX-b.left+12)+'px';tip.style.top=(e.clientY-b.top+12)+'px';};
   p.onmouseleave=()=>tip.style.display='none';
   if(+p.dataset.v>0)p.style.cursor='pointer';});
 document.getElementById('maplegend').innerHTML=`Shaded by 30-day sessions (darkest = ${esc((CDATA.rows[0]||{}).country||'')}, ${Math.round(max).toLocaleString()}). Hover any country for its total. Tiny territories (Hong Kong, Singapore) aren't drawn on this low-res map but are listed in the Country tab.`;
}

/* ---- LIVE (moving graphics) ---- */
let liveTimer=null,LIVEHIST=[],liveNumCur=0;
function animNum(el,to){const from=liveNumCur,t0=performance.now();
 (function step(t){const k=Math.min(1,(t-t0)/600);el.textContent=Math.round(from+(to-from)*k).toLocaleString();if(k<1)requestAnimationFrame(step);})(t0);
 liveNumCur=to;}
function sparkline(h){const W=760,H=130,p=8;
 if(h.length<2)return '<div class="z" style="padding:24px">collecting live signal… this fills in as polls arrive (every ~15s)</div>';
 const max=Math.max(1,...h.map(d=>d.v)),n=h.length;
 const X=i=>p+(W-2*p)*i/(n-1),Y=v=>H-p-(H-2*p)*v/max;
 const pts=h.map((d,i)=>`${X(i).toFixed(1)},${Y(d.v).toFixed(1)}`);
 const lx=X(n-1),ly=Y(h[n-1].v);
 return `<svg viewBox="0 0 ${W} ${H}" width="100%" style="max-height:160px">
  <defs><linearGradient id="lg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e74c3c" stop-opacity=".35"/><stop offset="1" stop-color="#e74c3c" stop-opacity="0"/></linearGradient></defs>
  <path d="M${X(0)},${H-p} L${pts.join(' L')} L${lx},${H-p} Z" fill="url(#lg)"/>
  <path d="M${pts.join(' L')}" fill="none" stroke="#e74c3c" stroke-width="2.5"/>
  <circle cx="${lx}" cy="${ly}" r="4" fill="#e74c3c"><animate attributeName="r" values="4;8;4" dur="1.5s" repeatCount="indefinite"/></circle>
  <text x="${lx-6}" y="${Math.max(16,ly-12)}" text-anchor="end" fill="#c0392b" font-weight="700">${h[n-1].v}</text></svg>`;}
async function pollLive(){
 try{const r=await fetch(location.origin+'/api/live');const j=await r.json();
  if(j.error){document.getElementById('livesites').textContent=j.error;return;}
  animNum(document.getElementById('livetot'),j.total_active);
  document.getElementById('livesites').textContent=`${j.active_sites} site(s) active · ${j.property_count} polled · ${new Date(j.generated_at).toLocaleTimeString()}`;
  LIVEHIST.push({v:j.total_active});if(LIVEHIST.length>40)LIVEHIST.shift();
  document.getElementById('livespark').innerHTML=sparkline(LIVEHIST);
  const a=j.rows.filter(x=>x.active>0).slice(0,15),mx=Math.max(1,...a.map(x=>x.active));
  document.getElementById('livebars').innerHTML=a.length?a.map(x=>
    `<div class="lrow"><span class="nm">${esc(x.property)}</span><div class="lbar" style="width:${Math.max(8,Math.round(100*x.active/mx))}%">${x.active}</div></div>`).join('')
    :'<div class="z" style="padding:16px">no active users right now — this lights up the moment someone lands on a site</div>';
 }catch(e){document.getElementById('livesites').textContent='fetch failed: '+e.message;}
}

/* ---- tabs ---- */
const TITLES={sites:'All Sites',global:'Global',charts:'Charts — all domains',keywords:'Keywords',visuals:'Visuals',countries:'User Country',map:'User Map',live:'Live'};
function showTab(tab){
 document.querySelectorAll('.side button').forEach(b=>b.classList.toggle('on',b.dataset.tab===tab));
 ['sites','global','charts','keywords','visuals','countries','map','live'].forEach(t=>document.getElementById('v-'+t).hidden=(t!==tab));
 document.getElementById('htitle').textContent=TITLES[tab];
 clearInterval(liveTimer);liveTimer=null;
 if(tab==='keywords')renderKeywords();
 if(tab==='global')renderGlobal();
 if(tab==='charts')renderCharts();
 if(tab==='visuals')renderVisuals();
 if(tab==='countries')renderCountries();
 if(tab==='map')renderMap();
 if(tab==='live'){pollLive();liveTimer=setInterval(pollLive,15000);}
}
document.querySelectorAll('.side button').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));

/* ---- header + load ---- */
function fmtAge(iso){const s=(Date.now()-new Date(iso))/1000;return s<90?'just now':s<3600?Math.round(s/60)+'m ago':Math.round(s/3600)+'h ago';}
function header(){
 const R=activeRows(),wl=WLBL(WIN).toLowerCase();
 const active=R.filter(x=>val(x,'m.sessions')>0).length;
 const tw=R.reduce((s,x)=>s+(val(x,'m.sessions')||0),0);
 const pv=R.reduce((s,x)=>s+(val(x,'m.screenPageViews')||0),0);
 const wsum=R.reduce((s,x)=>s+(val(x,'m.averageSessionDuration')||0)*(val(x,'m.sessions')||0),0);
 const wavg=tw?wsum/tw:0;
 document.getElementById('hstat').textContent=`${R.length} sites${exStore?' (store hidden)':''} · ${active} with traffic this ${wl} · ${Math.round(tw).toLocaleString()} sessions · ${Math.round(pv).toLocaleString()} views · ⌀ ${fmtDurP(wavg)} on site`;
 const age=(Date.now()-new Date(DATA.generated_at))/60000;
 document.getElementById('hbadge').innerHTML=`<span class="badge ${age>45?'stale':'fresh'}">${age>45?'STALE':'fresh'} · ${fmtAge(DATA.generated_at)}</span>`;
 document.getElementById('hgen').textContent='cache '+DATA.generated_at.slice(0,16).replace('T',' ')+'Z';
}
async function load(){
 if(window.__BOOT__){DATA=window.__BOOT__;window.__BOOT__=null;}
 else{const r=await fetch(location.origin+'/api/data');DATA=await r.json();}
 header();renderTable();
 const cur=document.querySelector('.side button.on').dataset.tab;
 if(cur==='visuals')renderVisuals();if(cur==='countries'){CDATA=null;renderCountries();}
 if(cur==='keywords'){KDATA=null;renderKeywords();}
}
document.getElementById('q').oninput=renderTable;
document.getElementById('onlyactive').onchange=renderTable;
(function(){const e=document.getElementById('exstore');e.checked=exStore;e.onchange=()=>{exStore=e.checked;localStorage.gaExStore=exStore?'1':'0';header();CDATA=CDATA;const cur=document.querySelector('.side button.on').dataset.tab;({sites:renderTable,global:renderGlobal,charts:renderCharts}[cur]||renderTable)();};})();
document.getElementById('dens').oninput=e=>{document.documentElement.style.setProperty('--fs',e.target.value+'px');document.documentElement.style.setProperty('--pad',Math.max(3,e.target.value-7)+'px');localStorage.gaDens=e.target.value;};
if(localStorage.gaDens){const el=document.getElementById('dens');el.value=localStorage.gaDens;el.dispatchEvent(new Event('input'));}
document.getElementById('refresh').onclick=async()=>{const m=document.getElementById('rmsg');m.textContent='refreshing… (~50s)';
 await fetch(location.origin+'/api/refresh',{method:'POST'});
 let n=0;const iv=setInterval(async()=>{n++;const j=await(await fetch(location.origin+'/api/status')).json();
   if(!j.refreshing){clearInterval(iv);m.textContent='updated ✓';CDATA=null;KDATA=null;load();setTimeout(()=>m.textContent='',3000);}else m.textContent=`refreshing… ${n*3}s`;},3000);};
renderWinSel();renderCols();load();
</script></body></html>"""


class H(BaseHTTPRequestHandler):
    def _auth(self) -> bool:
        h = self.headers.get("Authorization", "")
        if h.startswith("Basic "):
            try:
                u, p = base64.b64decode(h[6:]).decode().split(":", 1)
                if u == USER and p == PW:
                    return True
            except Exception:
                pass
        self.send_response(401)
        self.send_header("WWW-Authenticate", 'Basic realm="GA All Sites"')
        self.end_headers()
        return False

    def log_message(self, *a):
        pass

    def do_GET(self):
        if not self._auth():
            return
        if self.path == "/" or self.path.startswith("/index"):
            boot = CACHE.read_text() if CACHE.exists() else "null"
            self._send(200, "text/html", PAGE.replace("/*BOOTDATA*/null/*/BOOTDATA*/", boot).encode())
        elif self.path == "/api/data":
            self._send(200, "application/json",
                       CACHE.read_bytes() if CACHE.exists() else b'{"rows":[],"property_count":0,"generated_at":"1970-01-01T00:00:00"}')
        elif self.path == "/api/countries":
            self._send(200, "application/json",
                       COUNTRIES.read_bytes() if COUNTRIES.exists() else b'{"rows":[],"total_sessions":0}')
        elif self.path == "/api/keywords":
            self._send(200, "application/json",
                       KEYWORDS.read_bytes() if KEYWORDS.exists()
                       else b'{"gsc_site_count":0,"windows":{},"channels":{},"organic_sites":{}}')
        elif self.path == "/api/world":
            wp = HERE / "cache" / "world.geojson"
            self._send(200, "application/json",
                       wp.read_bytes() if wp.exists() else b'{"type":"FeatureCollection","features":[]}')
        elif self.path == "/api/status":
            self._send(200, "application/json", json.dumps({"refreshing": _refreshing["on"]}).encode())
        elif self.path == "/api/live":
            try:
                self._send(200, "application/json", json.dumps(fetch_live()).encode())
            except Exception as e:
                self._send(500, "application/json", json.dumps({"error": str(e)[:200]}).encode())
        else:
            self._send(404, "text/plain", b"not found")

    def do_POST(self):
        if not self._auth():
            return
        if self.path == "/api/refresh":
            threading.Thread(target=run_etl, daemon=True).start()
            self._send(202, "application/json", b'{"started":true}')
        else:
            self._send(404, "text/plain", b"not found")

    def _send(self, code, ctype, body):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


if __name__ == "__main__":
    print(f"GA All-Sites dashboard → http://127.0.0.1:{PORT}  (admin/DW2024!)")
    ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()