← back to Marketing Command Center

modules/performance/index.js

386 lines

// Performance module — a marketing performance dashboard for the Command Center.
// Email KPIs (sends/opens/clicks + open & click rate, list growth, top campaigns)
// plus web traffic (sessions/users/conversions) from GA4 when wired.
//
// LIVE web path: when GA4_PROPERTY_ID *and* GA4_ACCESS_TOKEN are set, web metrics
// are pulled from the GA4 Data API (runReport) via global fetch. Otherwise the
// whole dashboard runs in MOCK MODE and returns realistic, internally-consistent
// mock data tagged { mock: true } so the panel is fully usable without any creds.
//
// Read-only: nothing here sends or schedules. Secrets come from process.env only.

const GA4_API = 'https://analyticsdata.googleapis.com/v1beta';
const RANGES = { '7d': 7, '30d': 30, '90d': 90 };

const ga4Live = () =>
  Boolean(process.env.GA4_PROPERTY_ID && process.env.GA4_ACCESS_TOKEN);

// ── date helpers ──────────────────────────────────────────────────────────────
const pad = n => String(n).padStart(2, '0');
const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
function dayList(days, end = new Date()) {
  const out = [];
  const base = new Date(end.getFullYear(), end.getMonth(), end.getDate());
  for (let i = days - 1; i >= 0; i--) {
    out.push(isoDay(new Date(base.getTime() - i * 86400000)));
  }
  return out;
}
function normRange(r) {
  return RANGES[r] ? r : '30d';
}

// ── deterministic pseudo-random so mock data is stable per metric+day ──────────
// (no Math.random → numbers don't jitter between the overview & timeseries calls)
function seeded(str) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  // 0..1
  return ((h >>> 0) % 100000) / 100000;
}

// A plausible single-day value for a metric, with a gentle weekly rhythm + trend.
// Luxury wallcoverings brand: modest list, healthy engagement, weekday-skewed B2B.
const METRIC_BASE = {
  sends: 0,        // sends are event-driven (campaign days), handled specially
  opens: 95,
  clicks: 22,
  sessions: 240,
  users: 190,
  conversions: 6,
  signups: 9,
};
function dayValue(metric, iso, dayIndex, totalDays) {
  const base = METRIC_BASE[metric] != null ? METRIC_BASE[metric] : 50;
  const dow = new Date(iso + 'T00:00:00').getDay(); // 0 Sun..6 Sat
  // B2B weekday skew: weekends ~55% of a weekday.
  const weekFactor = dow === 0 || dow === 6 ? 0.55 : 1;
  // slow upward trend across the window (+18% end-to-start)
  const trend = 0.91 + 0.18 * (dayIndex / Math.max(1, totalDays - 1));
  // ±18% day jitter, deterministic
  const jitter = 0.82 + 0.36 * seeded(metric + iso);
  return Math.max(0, Math.round(base * weekFactor * trend * jitter));
}

// ── MOCK campaign cohort (drives email KPIs + sends/opens/clicks spikes) ────────
// Each campaign "happens" on a date inside the window so timeseries spikes line up.
function mockCampaigns(days, end = new Date()) {
  const names = [
    { name: 'Spring Grasscloth Edit', list: 'Trade & Designers' },
    { name: 'Trade Memo — Silk & Linen', list: 'Trade & Designers' },
    { name: 'Metallic Murals Lookbook', list: 'Retail Newsletter' },
    { name: 'Cork & Linen: The Tactile Issue', list: 'Trade & Designers' },
    { name: 'Glass Bead — Limited Run', list: 'Retail Newsletter' },
    { name: 'Hospitality Spec Guide', list: 'Trade & Designers' },
    { name: 'Flocked Damask Revival', list: 'Retail Newsletter' },
    { name: 'Summer Mural Preview', list: 'Trade & Designers' },
    { name: 'Memo Sample Reminder', list: 'Retail Newsletter' },
  ];
  // space campaigns roughly evenly across the window (older→newer)
  const n = Math.max(2, Math.round(days / 9)); // ~1 every 9 days
  const picks = [];
  const baseEnd = new Date(end.getFullYear(), end.getMonth(), end.getDate());
  for (let i = 0; i < n; i++) {
    const meta = names[i % names.length];
    const offset = Math.round(((i + 0.5) / n) * (days - 1));
    const date = isoDay(new Date(baseEnd.getTime() - (days - 1 - offset) * 86400000));
    const sends = 3200 + Math.round(seeded('snd' + date + meta.name) * 1900); // 3.2k–5.1k
    const openRate = 0.31 + seeded('opn' + date) * 0.18;                       // 31%–49%
    const opens = Math.round(sends * openRate);
    const clickRate = 0.06 + seeded('clk' + date) * 0.06;                      // 6%–12%
    const clicks = Math.round(opens * clickRate * 1.4);
    picks.push({
      id: 'mock-cmp-' + pad(i + 1),
      name: meta.name,
      list: meta.list,
      sent_at: date + 'T14:00:00Z',
      date,
      sends, opens, clicks,
      open_rate: +(opens / sends).toFixed(4),
      click_rate: +(clicks / sends).toFixed(4),
    });
  }
  return picks.sort((a, b) => b.date.localeCompare(a.date));
}

// Top content/pages a luxury wallcovering site would track in GA4 mock mode.
function mockTopPages(days) {
  const pages = [
    { path: '/collections/grasscloth', title: 'Grasscloth Collection' },
    { path: '/collections/silk', title: 'Silk Wallcoverings' },
    { path: '/murals', title: 'Murals & Scenics' },
    { path: '/trade', title: 'Trade Program' },
    { path: '/collections/cork', title: 'Cork Wallcoverings' },
    { path: '/journal/grasscloth-guide', title: 'The Grasscloth Guide' },
    { path: '/samples', title: 'Request Memo Samples' },
  ];
  const scale = days / 30;
  return pages.map((p, i) => {
    const views = Math.round((4200 - i * 480) * scale * (0.9 + seeded(p.path) * 0.3));
    const avgSec = 38 + Math.round(seeded('t' + p.path) * 120);
    return { ...p, views, avgSeconds: avgSec };
  }).sort((a, b) => b.views - a.views);
}

// ── GA4 Data API (runReport) ────────────────────────────────────────────────────
async function ga4RunReport(body) {
  const r = await fetch(
    `${GA4_API}/properties/${process.env.GA4_PROPERTY_ID}:runReport`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.GA4_ACCESS_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    }
  );
  const text = await r.text();
  let json;
  try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
  if (!r.ok) {
    const err = new Error(`GA4 runReport → ${r.status}`);
    err.status = r.status;
    err.detail = json;
    throw err;
  }
  return json;
}

// pull date-keyed web series (sessions/users/conversions) from GA4
async function ga4WebSeries(days) {
  const json = await ga4RunReport({
    dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
    dimensions: [{ name: 'date' }],
    metrics: [
      { name: 'sessions' },
      { name: 'totalUsers' },
      { name: 'conversions' },
    ],
    orderBys: [{ dimension: { dimensionName: 'date' } }],
  });
  const series = { sessions: {}, users: {}, conversions: {} };
  for (const row of json.rows || []) {
    const raw = row.dimensionValues[0].value;            // YYYYMMDD
    const iso = `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
    series.sessions[iso] = Number(row.metricValues[0].value || 0);
    series.users[iso] = Number(row.metricValues[1].value || 0);
    series.conversions[iso] = Number(row.metricValues[2].value || 0);
  }
  return series;
}

async function ga4TopPages(days) {
  const json = await ga4RunReport({
    dateRanges: [{ startDate: `${days}daysAgo`, endDate: 'today' }],
    dimensions: [{ name: 'pagePath' }, { name: 'pageTitle' }],
    metrics: [{ name: 'screenPageViews' }, { name: 'averageSessionDuration' }],
    orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
    limit: 8,
  });
  return (json.rows || []).map(row => ({
    path: row.dimensionValues[0].value,
    title: row.dimensionValues[1].value,
    views: Number(row.metricValues[0].value || 0),
    avgSeconds: Math.round(Number(row.metricValues[1].value || 0)),
  }));
}

// ── series builders ─────────────────────────────────────────────────────────────
// email / signup series are always mock-derived (CC not wired here); web series
// come from GA4 when live. Returns { [iso]: number } maps.
function emailSeries(days, campaigns) {
  const ds = dayList(days);
  const opens = {}, clicks = {}, sends = {}, signups = {};
  ds.forEach((iso, i) => {
    opens[iso] = dayValue('opens', iso, i, days);     // organic baseline
    clicks[iso] = dayValue('clicks', iso, i, days);
    sends[iso] = 0;
    signups[iso] = dayValue('signups', iso, i, days);
  });
  // overlay campaign-day spikes (the bulk of sends/opens/clicks land on send days)
  for (const c of campaigns) {
    if (sends[c.date] == null) continue;
    sends[c.date] += c.sends;
    opens[c.date] += c.opens;
    clicks[c.date] += c.clicks;
  }
  return { opens, clicks, sends, signups };
}

function webSeriesMock(days) {
  const ds = dayList(days);
  const sessions = {}, users = {}, conversions = {};
  ds.forEach((iso, i) => {
    sessions[iso] = dayValue('sessions', iso, i, days);
    users[iso] = dayValue('users', iso, i, days);
    conversions[iso] = dayValue('conversions', iso, i, days);
  });
  return { sessions, users, conversions };
}

const sum = obj => Object.values(obj).reduce((a, b) => a + b, 0);
// trend %: second half vs first half of the window (rounded to 1 decimal)
function trendPct(seriesMap) {
  const vals = Object.keys(seriesMap).sort().map(k => seriesMap[k]);
  if (vals.length < 2) return 0;
  const mid = Math.floor(vals.length / 2);
  const first = vals.slice(0, mid).reduce((a, b) => a + b, 0);
  const second = vals.slice(mid).reduce((a, b) => a + b, 0);
  if (first === 0) return second > 0 ? 100 : 0;
  return +(((second - first) / first) * 100).toFixed(1);
}

// ── module ─────────────────────────────────────────────────────────────────────
module.exports = {
  id: 'performance',
  title: 'Performance',
  icon: '📊',

  mount(router) {
    // GET /overview?range=30d — KPI summary (email + list growth + top campaigns
    //                            + web traffic when GA4 is wired)
    router.get('/overview', async (req, res) => {
      const range = normRange(req.query.range);
      const days = RANGES[range];
      const mockWeb = !ga4Live();

      const campaigns = mockCampaigns(days);
      const email = emailSeries(days, campaigns);

      // web series: GA4 live, else mock
      let web, webMock = true;
      try {
        web = mockWeb ? webSeriesMock(days) : await ga4WebSeries(days);
        webMock = mockWeb;
      } catch (e) {
        web = webSeriesMock(days);          // graceful fallback if GA4 errors
        webMock = true;
      }

      const sends = sum(email.sends);
      const opens = sum(email.opens);
      const clicks = sum(email.clicks);
      const signups = sum(email.signups);

      // Headline open/click RATES are derived from campaign aggregates (sent mail
      // only) so they read as believable per-send rates — the opens/clicks totals
      // above include an organic daily baseline, which would inflate a naive ratio.
      const campSends = campaigns.reduce((a, c) => a + c.sends, 0);
      const campOpens = campaigns.reduce((a, c) => a + c.opens, 0);
      const campClicks = campaigns.reduce((a, c) => a + c.clicks, 0);
      const openRate = campSends ? +(campOpens / campSends).toFixed(4) : 0;
      const clickRate = campSends ? +(campClicks / campSends).toFixed(4) : 0;

      // list growth: net new contacts over the window (signups, less light churn)
      const listEnd = 4218;
      const grossNew = signups;
      const churn = Math.round(grossNew * 0.12);
      const netNew = grossNew - churn;
      const listStart = listEnd - netNew;

      const sessions = sum(web.sessions);
      const users = sum(web.users);
      const conversions = sum(web.conversions);

      const topCampaigns = campaigns
        .filter(c => c.sends > 0)
        .slice()
        .sort((a, b) => b.open_rate - a.open_rate)
        .slice(0, 5);

      res.json({
        mock: true,                  // email side is always mock here
        web: { live: !webMock, mock: webMock },
        range,
        days,
        generatedAt: new Date().toISOString(),
        kpis: {
          sends:     { value: sends,  trend: trendPct(email.sends) },
          opens:     { value: opens,  trend: trendPct(email.opens) },
          openRate:  { value: openRate,  trend: trendPct(email.opens) },
          clicks:    { value: clicks, trend: trendPct(email.clicks) },
          clickRate: { value: clickRate, trend: trendPct(email.clicks) },
          listSize:  { value: listEnd, trend: listStart ? +(((listEnd - listStart) / listStart) * 100).toFixed(1) : 0 },
          netNewContacts: { value: netNew, churn },
          sessions:    { value: sessions,    trend: trendPct(web.sessions) },
          users:       { value: users,       trend: trendPct(web.users) },
          conversions: { value: conversions, trend: trendPct(web.conversions) },
        },
        listGrowth: { start: listStart, end: listEnd, grossNew, churn, netNew },
        topCampaigns,
      });
    });

    // GET /timeseries?metric=opens&range=30d — date-keyed series for the chart.
    // metrics: opens | clicks | sends | sessions | signups (+ users/conversions)
    router.get('/timeseries', async (req, res) => {
      const range = normRange(req.query.range);
      const days = RANGES[range];
      const metric = String(req.query.metric || 'opens');

      const emailMetrics = new Set(['opens', 'clicks', 'sends', 'signups']);
      const webMetrics = new Set(['sessions', 'users', 'conversions']);

      let map = null, isMock = true;

      if (emailMetrics.has(metric)) {
        const campaigns = mockCampaigns(days);
        map = emailSeries(days, campaigns)[metric];
      } else if (webMetrics.has(metric)) {
        if (ga4Live()) {
          try { map = (await ga4WebSeries(days))[metric]; isMock = false; }
          catch { map = webSeriesMock(days)[metric]; isMock = true; }
        } else {
          map = webSeriesMock(days)[metric];
        }
      } else {
        return res.status(400).json({
          ok: false,
          error: `unknown metric "${metric}"`,
          allowed: [...emailMetrics, ...webMetrics],
        });
      }

      const days_ = dayList(days);
      const series = days_.map(iso => ({ date: iso, value: map[iso] || 0 }));
      const total = series.reduce((a, p) => a + p.value, 0);
      res.json({
        mock: isMock,
        metric,
        range,
        total,
        trend: trendPct(map),
        series,
      });
    });

    // GET /top?kind=campaigns — top campaigns by open-rate, or top content/pages.
    router.get('/top', async (req, res) => {
      const kind = String(req.query.kind || 'campaigns');
      const range = normRange(req.query.range);
      const days = RANGES[range];

      if (kind === 'pages' || kind === 'content') {
        let pages, isMock = true;
        if (ga4Live()) {
          try { pages = await ga4TopPages(days); isMock = false; }
          catch { pages = mockTopPages(days); isMock = true; }
        } else {
          pages = mockTopPages(days);
        }
        return res.json({ mock: isMock, kind: 'pages', range, top: pages });
      }

      // default: campaigns (always mock here — CC engagement not wired in)
      const campaigns = mockCampaigns(days)
        .filter(c => c.sends > 0)
        .sort((a, b) => b.open_rate - a.open_rate);
      res.json({ mock: true, kind: 'campaigns', range, top: campaigns });
    });
  },
};