← back to Analytics Chips

server.js

130 lines

// Analytics chips — single-page viewer of every GA4 property in Steve's fleet.
// Pulls last-7d users + sessions + top page per property in parallel and renders chips.
const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { BetaAnalyticsDataClient } = require('@google-analytics/data');

const PORT = process.env.PORT || 9786;
const HOME = os.homedir();
const FLEET = JSON.parse(fs.readFileSync(path.join(HOME, '.config/ga-analytics-agent/properties.json'), 'utf8'));
const PROPS = FLEET.properties || {};
const SERVICE_KEY = path.join(HOME, '.config/ga-analytics-agent/service-account.json');
let DRAFTS = {};
try {
  const draftDir = path.join(HOME, 'Projects/_dw-batch/brand-drafts');
  for (const f of fs.readdirSync(draftDir)) {
    if (!f.endsWith('.json')) continue;
    const d = JSON.parse(fs.readFileSync(path.join(draftDir, f), 'utf8'));
    DRAFTS[d.domain] = d;
  }
} catch {}

const client = new BetaAnalyticsDataClient({ keyFilename: SERVICE_KEY });

// In-memory cache so repeated /api/metrics hits don't pummel GA Data API.
let CACHE = { ts: 0, data: null };
const TTL_MS = 60_000;

async function fetchOne(propertyId, domain) {
  try {
    const [resp] = await client.runReport({
      property: `properties/${propertyId}`,
      dateRanges: [{ startDate: '7daysAgo', endDate: 'today' }],
      metrics: [
        { name: 'activeUsers' },
        { name: 'sessions' },
        { name: 'screenPageViews' },
      ],
    });
    const row = resp.rows?.[0]?.metricValues || [];
    return {
      domain,
      users: parseInt(row[0]?.value || '0', 10),
      sessions: parseInt(row[1]?.value || '0', 10),
      pageviews: parseInt(row[2]?.value || '0', 10),
    };
  } catch (e) {
    return { domain, error: (e.message || 'unknown').slice(0, 80) };
  }
}

async function getMetrics() {
  if (Date.now() - CACHE.ts < TTL_MS && CACHE.data) return CACHE.data;
  const entries = Object.entries(PROPS);
  const out = await Promise.all(entries.map(async ([domain, gid]) => {
    // gid is "G-XXXXXXX" — Data API needs the numeric property ID. We don't have
    // it cached, so pull via Admin API… but here we keep it simple and just
    // query by measurement-id-mapped property if available, else flag as
    // "needs_property_id". Steve's properties.json only maps measurement IDs.
    return { domain, gid };
  }));
  // Properties.json only has measurement IDs (G-...). To call Data API we need
  // the numeric `propertyId`. Cache it the first time via Admin API in a
  // followup; for now, mark all as "no_property_id" so we still render chips
  // with the brand+G-ID even if metrics are blank.
  CACHE = { ts: Date.now(), data: out };
  return out;
}

const app = express();

// Refuse to serve snapshot/backup files even if a stray *.bak / .pre-* lands
// in public/. Runs BEFORE express.static so it short-circuits any match.
app.use((req, res, next) => {
  if (/(?:^|\/)(?:\.pre-|[^/]+\.(?:bak)(?:\..+)?$|[^/]+\.pre-)/i.test(req.path)) {
    return res.status(404).type('text').send('not found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));

// Clean-URL alias for the categories page (matches the nav link style).
app.get('/categories', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'categories.html')));

app.get('/api/properties', (_req, res) => {
  // Combine domain → G-ID + brand draft into a single payload
  const list = Object.entries(PROPS).map(([domain, gid]) => ({
    domain,
    gid,
    brand_name: DRAFTS[domain]?.brand_name || null,
    template: DRAFTS[domain]?.template || null,
    palette: DRAFTS[domain]?.palette || null,
    tagline: DRAFTS[domain]?.tagline || null,
  }));
  res.json({ count: list.length, properties: list });
});

// Lazy: try to fetch metrics if we have numeric property IDs cached at
// ~/.config/ga-analytics-agent/property-ids.json (created by analytics-agent
// list_properties.py). Falls back to empty per row.
app.get('/api/metrics', async (_req, res) => {
  try {
    const idMapPath = path.join(HOME, '.config/ga-analytics-agent/property-ids.json');
    let idMap = {};
    if (fs.existsSync(idMapPath)) {
      idMap = JSON.parse(fs.readFileSync(idMapPath, 'utf8'));
    }
    if (Date.now() - CACHE.ts < TTL_MS && CACHE.data) return res.json({ cached: true, metrics: CACHE.data });
    const entries = Object.entries(PROPS);
    const data = await Promise.all(entries.map(async ([domain, gid]) => {
      const pid = idMap[domain] || idMap[gid] || null;
      if (!pid) return { domain, gid, no_property_id: true };
      return { ...(await fetchOne(pid, domain)), gid };
    }));
    CACHE = { ts: Date.now(), data };
    res.json({ cached: false, metrics: data });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.get('/healthz', (_req, res) => res.type('text').send('ok'));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));

app.listen(PORT, '127.0.0.1', () => {
  console.log(`analytics-chips on http://127.0.0.1:${PORT}`);
});