[object Object]

← back to Ga Global Viewer

initial scaffold: GA global viewer with Chart.js, fetch_stats.py, auth-error state

2b47e12394767fbaeda9fb5b82fec1df2b513078 · 2026-06-01 11:00:23 -0700 · Steve Abrams

Files touched

Diff

commit 2b47e12394767fbaeda9fb5b82fec1df2b513078
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 11:00:23 2026 -0700

    initial scaffold: GA global viewer with Chart.js, fetch_stats.py, auth-error state
---
 .gitignore        |   10 +
 fetch_stats.py    |  345 +++++++++++++++++
 package-lock.json |  827 ++++++++++++++++++++++++++++++++++++++++
 package.json      |   13 +
 public/index.html | 1096 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         |  143 +++++++
 6 files changed, 2434 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f55e893
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+__pycache__/
+*.pyc
+cache/
diff --git a/fetch_stats.py b/fetch_stats.py
new file mode 100644
index 0000000..cd5fc60
--- /dev/null
+++ b/fetch_stats.py
@@ -0,0 +1,345 @@
+#!/usr/bin/env python3
+"""
+fetch_stats.py — Pull GA4 stats across all of Steve's properties.
+
+Writes JSON to stdout (or cache/stats.json if --cache flag given).
+On credential failure, writes a structured error object so the
+Node server can show a clear "creds needed" state.
+
+Usage:
+  python3 fetch_stats.py                     # stdout
+  python3 fetch_stats.py --cache             # writes cache/stats.json
+  python3 fetch_stats.py --cache --max 5     # limit to 5 properties (fast test)
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+
+PROPERTIES_JSON = Path.home() / ".config" / "ga-analytics-agent" / "properties.json"
+SA_KEY_PATH = Path.home() / ".config" / "ga-analytics-agent" / "service-account.json"
+
+# Properties on the do-not-touch list (per CLAUDE.md standing rules)
+SKIP_DOMAINS = {"designerwallcoverings.com", "studentdebtcrisis.org"}
+
+CACHE_DIR = Path(__file__).parent / "cache"
+
+
+def load_property_map() -> dict[str, str]:
+    """Returns {domain: measurement_id} from the cached properties.json."""
+    if not PROPERTIES_JSON.exists():
+        return {}
+    with open(PROPERTIES_JSON) as f:
+        d = json.load(f)
+    props = d.get("properties", {})
+    # Filter out do-not-touch domains
+    return {
+        domain: mid
+        for domain, mid in props.items()
+        if not any(skip in domain for skip in SKIP_DOMAINS)
+    }
+
+
+def load_property_id_map() -> dict[str, str]:
+    """Returns {measurement_id: numeric_property_id} from property-ids.json."""
+    pid_path = Path.home() / ".config" / "ga-analytics-agent" / "property-ids.json"
+    if not pid_path.exists():
+        return {}
+    with open(pid_path) as f:
+        d = json.load(f)
+    # Keys include both domain and G-XXXXX forms; pick G-XXXXX -> numeric
+    return {
+        k: v for k, v in d.items()
+        if k.startswith("G-")
+    }
+
+
+def check_auth() -> tuple[bool, str]:
+    """Returns (ok, error_message)."""
+    if not SA_KEY_PATH.exists():
+        return False, f"Service account key not found at {SA_KEY_PATH}. Run setup.sh."
+    try:
+        from google.oauth2 import service_account
+        from google.auth.transport.requests import Request
+
+        creds = service_account.Credentials.from_service_account_file(
+            str(SA_KEY_PATH),
+            scopes=["https://www.googleapis.com/auth/analytics.readonly"],
+        )
+        creds.refresh(Request())
+        if creds.valid:
+            return True, ""
+        return False, "Credentials refreshed but marked invalid."
+    except Exception as e:
+        err = str(e)
+        if "account not found" in err.lower() or "invalid_grant" in err.lower():
+            return False, (
+                "Service account key is revoked or the GCP project 'sheets-updater-485823' "
+                "has been deleted/disabled. A new service account key is needed."
+            )
+        return False, f"Auth error: {err}"
+
+
+def run_report_for_property(
+    client,
+    property_id: str,
+    start: str = "30daysAgo",
+    end: str = "yesterday",
+) -> dict | None:
+    """Run a GA4 Data API report for one property. Returns dict or None on error."""
+    from google.analytics.data_v1beta.types import (
+        DateRange, Dimension, Metric, RunReportRequest,
+        OrderBy,
+    )
+
+    prop_path = f"properties/{property_id}"
+
+    try:
+        # Time series: sessions + users + pageviews by date
+        ts_req = RunReportRequest(
+            property=prop_path,
+            date_ranges=[DateRange(start_date=start, end_date=end)],
+            dimensions=[Dimension(name="date")],
+            metrics=[
+                Metric(name="sessions"),
+                Metric(name="activeUsers"),
+                Metric(name="screenPageViews"),
+            ],
+            order_bys=[OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name="date"))],
+            limit=60,
+        )
+        ts_resp = client.run_report(ts_req)
+
+        timeseries = []
+        total_sessions = 0
+        total_users = 0
+        total_pageviews = 0
+        for row in ts_resp.rows:
+            date = row.dimension_values[0].value
+            s = int(row.metric_values[0].value)
+            u = int(row.metric_values[1].value)
+            pv = int(row.metric_values[2].value)
+            timeseries.append({"date": date, "sessions": s, "users": u, "pageviews": pv})
+            total_sessions += s
+            total_users += u
+            total_pageviews += pv
+
+        # Top pages
+        pages_req = RunReportRequest(
+            property=prop_path,
+            date_ranges=[DateRange(start_date=start, end_date=end)],
+            dimensions=[Dimension(name="pagePath")],
+            metrics=[Metric(name="screenPageViews")],
+            order_bys=[OrderBy(
+                metric=OrderBy.MetricOrderBy(metric_name="screenPageViews"),
+                desc=True,
+            )],
+            limit=10,
+        )
+        pages_resp = client.run_report(pages_req)
+        top_pages = [
+            {"path": r.dimension_values[0].value, "pageviews": int(r.metric_values[0].value)}
+            for r in pages_resp.rows
+        ]
+
+        # Top sources
+        sources_req = RunReportRequest(
+            property=prop_path,
+            date_ranges=[DateRange(start_date=start, end_date=end)],
+            dimensions=[Dimension(name="sessionSource")],
+            metrics=[Metric(name="sessions")],
+            order_bys=[OrderBy(
+                metric=OrderBy.MetricOrderBy(metric_name="sessions"),
+                desc=True,
+            )],
+            limit=10,
+        )
+        src_resp = client.run_report(sources_req)
+        top_sources = [
+            {"source": r.dimension_values[0].value, "sessions": int(r.metric_values[0].value)}
+            for r in src_resp.rows
+        ]
+
+        return {
+            "timeseries": timeseries,
+            "totals": {
+                "sessions": total_sessions,
+                "users": total_users,
+                "pageviews": total_pageviews,
+            },
+            "top_pages": top_pages,
+            "top_sources": top_sources,
+        }
+
+    except Exception as e:
+        err_str = str(e)
+        if "403" in err_str or "PERMISSION_DENIED" in err_str:
+            return {"error": "permission_denied", "detail": err_str[:200]}
+        if "404" in err_str or "NOT_FOUND" in err_str:
+            return {"error": "property_not_found", "detail": err_str[:200]}
+        return {"error": "api_error", "detail": err_str[:200]}
+
+
+def merge_timeseries(all_series: list[list[dict]]) -> list[dict]:
+    """Merge per-property timeseries into a combined global timeseries."""
+    combined: dict[str, dict] = {}
+    for series in all_series:
+        for row in series:
+            d = row["date"]
+            if d not in combined:
+                combined[d] = {"date": d, "sessions": 0, "users": 0, "pageviews": 0}
+            combined[d]["sessions"] += row["sessions"]
+            combined[d]["users"] += row["users"]
+            combined[d]["pageviews"] += row["pageviews"]
+    return sorted(combined.values(), key=lambda r: r["date"])
+
+
+def merge_top_list(all_lists: list[list[dict]], key: str, value: str, top_n: int = 15) -> list[dict]:
+    """Merge per-property top-N lists into a global top-N."""
+    combined: dict[str, int] = {}
+    for lst in all_lists:
+        for item in lst:
+            k = item[key]
+            combined[k] = combined.get(k, 0) + item[value]
+    sorted_items = sorted(combined.items(), key=lambda x: x[1], reverse=True)
+    return [{key: k, value: v} for k, v in sorted_items[:top_n]]
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--cache", action="store_true", help="Write to cache/stats.json")
+    ap.add_argument("--max", type=int, default=0, help="Limit number of properties queried")
+    ap.add_argument("--start", default="30daysAgo")
+    ap.add_argument("--end", default="yesterday")
+    args = ap.parse_args()
+
+    result = {
+        "generated_at": datetime.utcnow().isoformat() + "Z",
+        "date_range": {"start": args.start, "end": args.end},
+        "auth_ok": False,
+        "auth_error": None,
+        "properties_count": 0,
+        "properties_queried": 0,
+        "properties_with_data": 0,
+        "global": {
+            "timeseries": [],
+            "totals": {"sessions": 0, "users": 0, "pageviews": 0},
+            "top_pages": [],
+            "top_sources": [],
+        },
+        "per_property": [],
+    }
+
+    # Load property map from cache
+    prop_map = load_property_map()  # domain -> G-ID
+    pid_map = load_property_id_map()  # G-ID -> numeric property_id
+    result["properties_count"] = len(prop_map)
+
+    # Auth check
+    auth_ok, auth_error = check_auth()
+    result["auth_ok"] = auth_ok
+    result["auth_error"] = auth_error
+
+    if not auth_ok:
+        if args.cache:
+            CACHE_DIR.mkdir(exist_ok=True)
+            (CACHE_DIR / "stats.json").write_text(json.dumps(result, indent=2))
+        else:
+            print(json.dumps(result, indent=2))
+        return 0
+
+    # Auth is good — run reports
+    from google.oauth2 import service_account
+    from google.analytics.data_v1beta import BetaAnalyticsDataClient
+
+    creds = service_account.Credentials.from_service_account_file(
+        str(SA_KEY_PATH),
+        scopes=["https://www.googleapis.com/auth/analytics.readonly"],
+    )
+    client = BetaAnalyticsDataClient(credentials=creds)
+
+    per_property_timeseries = []
+    per_property_pages = []
+    per_property_sources = []
+    global_totals = {"sessions": 0, "users": 0, "pageviews": 0}
+
+    items = list(prop_map.items())
+    if args.max:
+        items = items[: args.max]
+
+    result["properties_queried"] = len(items)
+    queried_count = 0
+
+    for domain, mid in items:
+        numeric_id = pid_map.get(mid)
+        if not numeric_id:
+            result["per_property"].append({
+                "domain": domain,
+                "measurement_id": mid,
+                "numeric_id": None,
+                "error": "no_numeric_id",
+            })
+            continue
+
+        queried_count += 1
+        prop_result = run_report_for_property(client, numeric_id, args.start, args.end)
+
+        if prop_result is None or "error" in prop_result:
+            result["per_property"].append({
+                "domain": domain,
+                "measurement_id": mid,
+                "numeric_id": numeric_id,
+                "error": prop_result.get("error") if prop_result else "null_response",
+                "detail": prop_result.get("detail", "") if prop_result else "",
+            })
+            continue
+
+        # Has real data
+        result["properties_with_data"] += 1
+        totals = prop_result["totals"]
+        global_totals["sessions"] += totals["sessions"]
+        global_totals["users"] += totals["users"]
+        global_totals["pageviews"] += totals["pageviews"]
+
+        per_property_timeseries.append(prop_result["timeseries"])
+        per_property_pages.append(prop_result["top_pages"])
+        per_property_sources.append(prop_result["top_sources"])
+
+        result["per_property"].append({
+            "domain": domain,
+            "measurement_id": mid,
+            "numeric_id": numeric_id,
+            "totals": totals,
+            "timeseries": prop_result["timeseries"],
+            "top_pages": prop_result["top_pages"],
+            "top_sources": prop_result["top_sources"],
+        })
+
+        # Rate-limit pause
+        if queried_count % 10 == 0:
+            time.sleep(1)
+
+    # Build global aggregates
+    result["global"]["totals"] = global_totals
+    result["global"]["timeseries"] = merge_timeseries(per_property_timeseries)
+    result["global"]["top_pages"] = merge_top_list(per_property_pages, "path", "pageviews")
+    result["global"]["top_sources"] = merge_top_list(per_property_sources, "source", "sessions")
+
+    if args.cache:
+        CACHE_DIR.mkdir(exist_ok=True)
+        out_path = CACHE_DIR / "stats.json"
+        out_path.write_text(json.dumps(result, indent=2))
+        print(f"Wrote {out_path}", file=sys.stderr)
+    else:
+        print(json.dumps(result, indent=2))
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..ea5d25e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,827 @@
+{
+  "name": "ga-global-viewer",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "ga-global-viewer",
+      "version": "1.0.0",
+      "dependencies": {
+        "express": "^4.22.2"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7dd26b2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "ga-global-viewer",
+  "version": "1.0.0",
+  "description": "Google Analytics global fleet dashboard for Steve Abrams sites",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "dev": "node server.js"
+  },
+  "dependencies": {
+    "express": "^4.22.2"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..2bf81be
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,1096 @@
+<!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>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..cca2750
--- /dev/null
+++ b/server.js
@@ -0,0 +1,143 @@
+'use strict';
+/**
+ * ga-global-viewer — Express server
+ *
+ * GET /          → serves the dashboard HTML
+ * GET /api/stats → returns stats JSON (from cache or fresh fetch)
+ * GET /api/refresh → triggers a fresh python3 fetch_stats.py --cache run
+ */
+
+const express = require('express');
+const { execFile, spawn } = require('child_process');
+const path = require('path');
+const fs = require('fs');
+
+const PORT = 9768;
+const CACHE_FILE = path.join(__dirname, 'cache', 'stats.json');
+const FETCH_SCRIPT = path.join(__dirname, 'fetch_stats.py');
+
+// Track whether a refresh is currently running
+let refreshRunning = false;
+let refreshLog = [];
+
+const app = express();
+app.use(express.json());
+
+// ── /api/stats ────────────────────────────────────────────────────────────────
+app.get('/api/stats', (req, res) => {
+  // If cache exists, serve it
+  if (fs.existsSync(CACHE_FILE)) {
+    try {
+      const raw = fs.readFileSync(CACHE_FILE, 'utf8');
+      const data = JSON.parse(raw);
+      data._cache_hit = true;
+      data._cache_age_s = Math.floor((Date.now() - fs.statSync(CACHE_FILE).mtimeMs) / 1000);
+      return res.json(data);
+    } catch (e) {
+      // fall through to fresh fetch
+    }
+  }
+
+  // No cache — run the python script synchronously (first-run)
+  res.setHeader('Content-Type', 'application/json');
+  res.setHeader('X-Fetching', 'true');
+
+  runFetch((err, data) => {
+    if (err) {
+      return res.status(500).json({
+        auth_ok: false,
+        auth_error: `Failed to run fetch_stats.py: ${err.message}`,
+        properties_count: 0,
+        per_property: [],
+        global: { totals: {sessions:0,users:0,pageviews:0}, timeseries:[], top_pages:[], top_sources:[] },
+      });
+    }
+    data._cache_hit = false;
+    res.json(data);
+  });
+});
+
+// ── /api/refresh ──────────────────────────────────────────────────────────────
+app.get('/api/refresh', (req, res) => {
+  if (refreshRunning) {
+    return res.json({ status: 'already_running', log: refreshLog });
+  }
+  refreshRunning = true;
+  refreshLog = ['Starting fresh GA4 data pull...'];
+  res.json({ status: 'started' });
+
+  const child = spawn('python3', [FETCH_SCRIPT, '--cache'], {
+    cwd: __dirname,
+    env: { ...process.env, PYTHONUNBUFFERED: '1' },
+  });
+
+  child.stdout.on('data', d => refreshLog.push(d.toString().trim()));
+  child.stderr.on('data', d => refreshLog.push(d.toString().trim()));
+  child.on('close', code => {
+    refreshRunning = false;
+    refreshLog.push(`Done (exit ${code})`);
+  });
+});
+
+// ── /api/refresh-status ───────────────────────────────────────────────────────
+app.get('/api/refresh-status', (req, res) => {
+  res.json({ running: refreshRunning, log: refreshLog });
+});
+
+// ── /api/properties ───────────────────────────────────────────────────────────
+app.get('/api/properties', (req, res) => {
+  const pPath = path.join(
+    process.env.HOME, '.config', 'ga-analytics-agent', 'properties.json'
+  );
+  if (!fs.existsSync(pPath)) {
+    return res.json({ count: 0, properties: {} });
+  }
+  try {
+    const d = JSON.parse(fs.readFileSync(pPath, 'utf8'));
+    res.json({
+      count: Object.keys(d.properties || {}).length,
+      account: d.account_display_name,
+      generated: d._generated,
+      properties: d.properties || {},
+    });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ── static (public/) ──────────────────────────────────────────────────────────
+app.use(express.static(path.join(__dirname, 'public')));
+
+// ── root → dashboard ──────────────────────────────────────────────────────────
+app.get('/', (req, res) => {
+  res.sendFile(path.join(__dirname, 'public', 'index.html'));
+});
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+function runFetch(cb) {
+  execFile('python3', [FETCH_SCRIPT, '--cache'], {
+    cwd: __dirname,
+    timeout: 300_000, // 5 min for full fleet run
+    env: { ...process.env, PYTHONUNBUFFERED: '1' },
+  }, (err, stdout, stderr) => {
+    if (err) return cb(err);
+    if (fs.existsSync(CACHE_FILE)) {
+      try {
+        return cb(null, JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')));
+      } catch (e) {
+        return cb(e);
+      }
+    }
+    try {
+      return cb(null, JSON.parse(stdout));
+    } catch (e) {
+      return cb(new Error(`JSON parse error: ${e.message}\nstdout: ${stdout.slice(0,200)}`));
+    }
+  });
+}
+
+// ── start ─────────────────────────────────────────────────────────────────────
+app.listen(PORT, () => {
+  console.log(`ga-global-viewer running at http://localhost:${PORT}`);
+  console.log(`Cache: ${CACHE_FILE}`);
+});

(oldest)  ·  back to Ga Global Viewer  ·  add pm2 ecosystem config for ga-global-viewer on port 9768 b6723c8 →