← 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 });
});
},
};