← back to Marketing Command Center
modules/segment-perf/index.js
308 lines
// Segment Performance module — splits email KPIs by audience segment so the
// marketing lead can see at a glance which cohorts are healthy vs. tanking and
// where revenue per recipient actually lives. Read-only analysis; never sends.
//
// KPIs surfaced per segment:
// • open rate (opens / sends)
// • CTOR (clicks / opens — engagement quality of openers)
// • click rate (clicks / sends — overall response)
// • est revenue / recipient (orders × AOV / sends)
//
// Each KPI is colour-coded against luxury B2B email benchmarks (see HEALTH). A
// 90-day daily trend per segment + metric powers the SVG bar chart panel-side.
//
// Data: deterministic mock (CTCT reporting + Shopify revenue attribution not
// wired here). Every response carries { mock: true } so the panel can banner.
//
// The four segments mirror DW's real audience lens:
// designers — interior designers / trade (mainstream luxury residential)
// commercial — hospitality, healthcare, corporate spec / Type II vinyls
// retail — direct-to-consumer newsletter readers
// at-risk — historically engaged contacts who've gone cold (winback)
const SEGMENTS = [
{
id: 'designers',
name: 'Interior Designers',
description: 'Trade designers + residential specifiers — the core luxury book of business.',
icon: '🪷',
listSize: 2840,
sendsPerCampaign: 2620,
// realistic baselines for a strong trade list
baseOpenRate: 0.42, // 42%
baseCTOR: 0.155, // 15.5%
aov: 1480, // avg order value for a trade specifier
orderRate: 0.014, // 1.4% of recipients buy within attribution window
},
{
id: 'commercial',
name: 'Commercial / Hospitality',
description: 'Architects + hospitality / healthcare spec — fewer eyes but high project value.',
icon: '🏛',
listSize: 612,
sendsPerCampaign: 590,
baseOpenRate: 0.385,
baseCTOR: 0.122,
aov: 6200, // big contract project
orderRate: 0.006, // rare but huge
},
{
id: 'retail',
name: 'Retail',
description: 'Direct-to-consumer newsletter — broadest list, lowest intent.',
icon: '🛍',
listSize: 7415,
sendsPerCampaign: 7080,
baseOpenRate: 0.275,
baseCTOR: 0.072,
aov: 340,
orderRate: 0.0085,
},
{
id: 'at-risk',
name: 'At-Risk (Winback)',
description: 'Previously engaged contacts that have gone cold — winback campaigns target these.',
icon: '⚠',
listSize: 1190,
sendsPerCampaign: 1150,
baseOpenRate: 0.082,
baseCTOR: 0.034,
aov: 410,
orderRate: 0.0011,
},
];
// Health thresholds — luxury B2B email standards. Anything below `bad` is red,
// between bad and good is amber, at-or-above good is green. Higher-is-better
// for every metric we track.
const HEALTH = {
openRate: { bad: 0.20, good: 0.35 },
ctor: { bad: 0.08, good: 0.13 },
clickRate: { bad: 0.025, good: 0.055 },
revPerRecipient: { bad: 0.50, good: 2.00 },
};
function healthBand(metric, value) {
const h = HEALTH[metric];
if (!h) return 'unknown';
if (value < h.bad) return 'bad';
if (value < h.good) return 'ok';
return 'good';
}
// ── deterministic PRNG (mulberry32) — same generator used by the other mock
// modules in this app so behaviour stays stable across calls.
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s |= 0; s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashSeed(str) {
let h = 2166136261 >>> 0;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
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;
}
// Generate a 90-day trend for one segment+metric. Approach: ~9 campaigns spaced
// evenly across the window (one every ~10 days). Each campaign yields a per-send
// metric value; non-campaign days inherit the previous campaign's value so the
// SVG bars read as a step-down history. At-Risk gets a slight downward drift to
// look like an audience genuinely going cold.
function trendFor(segment, metric, days = 90) {
const dates = dayList(days);
const rnd = mulberry32(hashSeed(`${segment.id}|${metric}|v2`));
const nCampaigns = Math.max(2, Math.round(days / 10));
// pick campaign dates (indices into `dates`) — evenly distributed
const campIdxs = [];
for (let i = 0; i < nCampaigns; i++) {
campIdxs.push(Math.round(((i + 0.5) / nCampaigns) * (days - 1)));
}
// Step 1: compute campaign-day values
const campValues = campIdxs.map((idx, i) => {
// jitter ±18% on the base rate
const jitter = 0.82 + 0.36 * rnd();
// slight directional drift across the window
const driftSlope =
segment.id === 'at-risk' ? -0.25 : // going cold
segment.id === 'commercial' ? +0.12 : // tightening up
segment.id === 'designers' ? +0.05 : // mild improvement
-0.04; // retail mostly flat
const drift = 1 + driftSlope * ((i / Math.max(1, nCampaigns - 1)) - 0.5);
let value;
if (metric === 'openRate') {
value = segment.baseOpenRate * jitter * drift;
} else if (metric === 'ctor') {
value = segment.baseCTOR * jitter * drift;
} else if (metric === 'clickRate') {
// click rate = open rate × CTOR with their own jitter
const o = segment.baseOpenRate * (0.85 + 0.30 * rnd());
const c = segment.baseCTOR * (0.85 + 0.30 * rnd());
value = o * c * drift;
} else if (metric === 'revPerRecipient') {
// expected dollars per recipient = orderRate × AOV, with project-deal noise
const orderRate = segment.orderRate * (0.6 + 1.0 * rnd()) * drift;
value = orderRate * segment.aov;
} else {
value = 0;
}
return value;
});
// Step 2: build per-day series by holding the last campaign value
const series = [];
let lastVal = campValues[0];
let lastCampIdx = -1;
for (let i = 0; i < days; i++) {
const ci = campIdxs.indexOf(i);
if (ci >= 0) { lastVal = campValues[ci]; lastCampIdx = ci; }
series.push({
date: dates[i],
value: +lastVal.toFixed(metric === 'revPerRecipient' ? 3 : 4),
isCampaign: ci >= 0,
campaignIndex: lastCampIdx,
});
}
const mean = campValues.reduce((a, b) => a + b, 0) / campValues.length;
// 30-day trend: avg of last 30 campaign-day values vs first 30
const last30 = series.slice(-30).map(s => s.value);
const first30 = series.slice(0, 30).map(s => s.value);
const lastAvg = last30.reduce((a, b) => a + b, 0) / last30.length;
const firstAvg = first30.reduce((a, b) => a + b, 0) / first30.length;
const pctChange = firstAvg ? +(((lastAvg - firstAvg) / firstAvg) * 100).toFixed(1) : 0;
return { series, mean: +mean.toFixed(4), campaignDays: campIdxs.map(i => dates[i]), pctChange };
}
// Headline KPI block for one segment — uses the last-30-day average from each
// metric's trend so the headline matches the chart.
function kpisFor(segment) {
const openTrend = trendFor(segment, 'openRate');
const ctorTrend = trendFor(segment, 'ctor');
const clickTrend = trendFor(segment, 'clickRate');
const revTrend = trendFor(segment, 'revPerRecipient');
// 30-day windowed averages (chart-aligned)
const avg = arr => arr.reduce((a, b) => a + b, 0) / arr.length;
const openRate = avg(openTrend.series.slice(-30).map(s => s.value));
const ctor = avg(ctorTrend.series.slice(-30).map(s => s.value));
const clickRate = avg(clickTrend.series.slice(-30).map(s => s.value));
const revPerRecipient = avg(revTrend.series.slice(-30).map(s => s.value));
return {
openRate: { value: +openRate.toFixed(4), band: healthBand('openRate', openRate), trend: openTrend.pctChange },
ctor: { value: +ctor.toFixed(4), band: healthBand('ctor', ctor), trend: ctorTrend.pctChange },
clickRate: { value: +clickRate.toFixed(4), band: healthBand('clickRate', clickRate), trend: clickTrend.pctChange },
revPerRecipient: { value: +revPerRecipient.toFixed(3), band: healthBand('revPerRecipient', revPerRecipient), trend: revTrend.pctChange },
};
}
// Overall band: worst single KPI drives the segment-level health colour, so the
// summary card honestly flags problem cohorts even if 3 of 4 metrics look fine.
function overallBand(kpis) {
const order = { bad: 0, ok: 1, good: 2, unknown: 3 };
return Object.values(kpis).reduce((worst, k) =>
(order[k.band] < order[worst] ? k.band : worst), 'good');
}
// ── module ─────────────────────────────────────────────────────────────────────
module.exports = {
id: 'segment-perf',
title: 'Segment Performance',
icon: '📈',
// exposed for unit tests / sibling modules if they ever need to reuse the model
_segments: SEGMENTS,
_health: HEALTH,
_trendFor: trendFor,
mount(router) {
// GET /summary — one card's worth of data per segment
router.get('/summary', (_req, res) => {
const segments = SEGMENTS.map(s => {
const kpis = kpisFor(s);
return {
id: s.id,
name: s.name,
description: s.description,
icon: s.icon,
listSize: s.listSize,
sendsPerCampaign: s.sendsPerCampaign,
kpis,
overallBand: overallBand(kpis),
};
});
res.json({
mock: true,
generatedAt: new Date().toISOString(),
benchmarks: HEALTH,
segments,
});
});
// GET /trend?segmentId=designers&metric=openRate&days=90 — bar chart series
router.get('/trend', (req, res) => {
const segmentId = String(req.query.segmentId || '');
const metric = String(req.query.metric || 'openRate');
const days = Math.max(7, Math.min(180, Number(req.query.days) || 90));
const seg = SEGMENTS.find(s => s.id === segmentId);
if (!seg) {
return res.status(404).json({
ok: false,
error: `unknown segment "${segmentId}"`,
allowed: SEGMENTS.map(s => s.id),
});
}
const allowedMetrics = ['openRate', 'ctor', 'clickRate', 'revPerRecipient'];
if (!allowedMetrics.includes(metric)) {
return res.status(400).json({
ok: false,
error: `unknown metric "${metric}"`,
allowed: allowedMetrics,
});
}
const t = trendFor(seg, metric, days);
const benchmark = HEALTH[metric];
res.json({
mock: true,
segmentId,
segmentName: seg.name,
metric,
days,
benchmark, // { bad, good }
mean: t.mean,
pctChange: t.pctChange, // last 30d vs first 30d
campaignDays: t.campaignDays,
series: t.series,
});
});
// GET /benchmarks — just the thresholds (panel uses these to colour the legend)
router.get('/benchmarks', (_req, res) => {
res.json({ mock: true, benchmarks: HEALTH });
});
},
};